Files
hate_crack/tests/test_main_utils.py
T
Justin BollingerandClaude Opus 4.6 fa07d37a84 fix: write output files to user's CWD, not install directory
The bash shim uses `uv run --directory <install_dir>` which changes the
process CWD to the install directory. _ensure_hashfile_in_cwd() and the
Hashview download path used os.getcwd() to determine the target directory
for output files (.out, .nt, etc.), causing them to land in the install
directory instead of where the user ran the command.

Add orig_cwd() helper that reads HATE_CRACK_ORIG_CWD (set by the shim)
and use it in _ensure_hashfile_in_cwd(), the Hashview download path, and
the potfile fallback path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:43:51 -04:00

594 lines
22 KiB
Python

"""Tests for utility functions in hate_crack/main.py."""
import os
from unittest.mock import MagicMock, patch
import pytest
from hate_crack.main import _dedup_netntlm_by_username
@pytest.fixture
def main_module(hc_module):
return hc_module._main
class TestAppendPotfileArg:
def test_appends_potfile_path(self, main_module):
with patch.object(main_module, "hcatPotfilePath", "/some/path"):
cmd = []
main_module._append_potfile_arg(cmd)
assert "--potfile-path=/some/path" in cmd
def test_no_append_empty_potfile(self, main_module):
with patch.object(main_module, "hcatPotfilePath", ""):
cmd = []
main_module._append_potfile_arg(cmd)
assert cmd == []
def test_disabled_by_flag(self, main_module):
with patch.object(main_module, "hcatPotfilePath", "/some/path"):
cmd = []
main_module._append_potfile_arg(cmd, use_potfile_path=False)
assert cmd == []
def test_explicit_potfile_overrides_global(self, main_module):
with patch.object(main_module, "hcatPotfilePath", "/global/path"):
cmd = []
main_module._append_potfile_arg(cmd, potfile_path="/custom/path")
assert "--potfile-path=/custom/path" in cmd
assert "--potfile-path=/global/path" not in cmd
def test_explicit_potfile_when_global_empty(self, main_module):
with patch.object(main_module, "hcatPotfilePath", ""):
cmd = []
main_module._append_potfile_arg(cmd, potfile_path="/explicit/path")
assert "--potfile-path=/explicit/path" in cmd
class TestGenerateSessionId:
def test_basic_filename(self, main_module):
with patch("hate_crack.main.hcatHashFile", "/tmp/myfile.txt", create=True):
result = main_module.generate_session_id()
assert result == "myfile"
def test_with_hyphens_and_underscores(self, main_module):
with patch("hate_crack.main.hcatHashFile", "/path/to/my-file_v2.txt", create=True):
result = main_module.generate_session_id()
assert result == "my-file_v2"
def test_dots_replaced(self, main_module):
with patch("hate_crack.main.hcatHashFile", "/tmp/file.with.dots.txt", create=True):
result = main_module.generate_session_id()
assert result == "file_with_dots"
def test_spaces_replaced(self, main_module):
with patch("hate_crack.main.hcatHashFile", "/tmp/file with spaces.txt", create=True):
result = main_module.generate_session_id()
assert result == "file_with_spaces"
def test_returns_nonempty_string(self, main_module):
with patch("hate_crack.main.hcatHashFile", "/tmp/somefile.txt", create=True):
result = main_module.generate_session_id()
assert isinstance(result, str)
assert len(result) > 0
def test_only_safe_chars(self, main_module):
with patch("hate_crack.main.hcatHashFile", "/tmp/f!le@na#me.txt", create=True):
result = main_module.generate_session_id()
import re
assert re.fullmatch(r"[a-zA-Z0-9_-]+", result) is not None
class TestEnsureHashfileInCwd:
def test_none_returns_none(self, main_module):
result = main_module._ensure_hashfile_in_cwd(None)
assert result is None
def test_empty_string_returns_empty(self, main_module):
result = main_module._ensure_hashfile_in_cwd("")
assert result == ""
def test_relative_path_unchanged(self, main_module):
result = main_module._ensure_hashfile_in_cwd("relative/path.txt")
assert result == "relative/path.txt"
def test_already_in_cwd(self, main_module, tmp_path, monkeypatch):
target = tmp_path / "hashfile.txt"
target.write_text("hashes")
monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(tmp_path))
result = main_module._ensure_hashfile_in_cwd(str(target))
assert result == str(target)
def test_different_dir_existing_file_in_cwd(self, main_module, tmp_path, monkeypatch):
# File exists in a different directory
other_dir = tmp_path / "other"
other_dir.mkdir()
source_file = other_dir / "hashes.txt"
source_file.write_text("hashes")
# A file with the same name already exists in cwd
cwd_dir = tmp_path / "cwd"
cwd_dir.mkdir()
cwd_copy = cwd_dir / "hashes.txt"
cwd_copy.write_text("cwd version")
monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(cwd_dir))
result = main_module._ensure_hashfile_in_cwd(str(source_file))
assert result == str(cwd_copy)
def test_different_dir_creates_symlink(self, main_module, tmp_path, monkeypatch):
# Source file in a different directory, nothing in cwd
other_dir = tmp_path / "other"
other_dir.mkdir()
source_file = other_dir / "hashes.txt"
source_file.write_text("hashes")
cwd_dir = tmp_path / "cwd"
cwd_dir.mkdir()
monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(cwd_dir))
result = main_module._ensure_hashfile_in_cwd(str(source_file))
expected = str(cwd_dir / "hashes.txt")
assert result == expected
assert os.path.exists(expected)
def test_uses_orig_cwd_not_process_cwd(self, main_module, tmp_path, monkeypatch):
"""Output files go to HATE_CRACK_ORIG_CWD, not the process CWD."""
# Simulate: process CWD is the install dir, orig CWD is the user's dir
install_dir = tmp_path / "install"
install_dir.mkdir()
user_dir = tmp_path / "user"
user_dir.mkdir()
other_dir = tmp_path / "other"
other_dir.mkdir()
source_file = other_dir / "hashes.txt"
source_file.write_text("hashes")
monkeypatch.chdir(install_dir)
monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(user_dir))
result = main_module._ensure_hashfile_in_cwd(str(source_file))
# Should land in user_dir, NOT install_dir
assert result == str(user_dir / "hashes.txt")
assert os.path.exists(user_dir / "hashes.txt")
assert not os.path.exists(install_dir / "hashes.txt")
class TestRunHashcatShow:
def _make_mock_result(self, stdout_bytes):
mock_result = MagicMock()
mock_result.stdout = stdout_bytes
return mock_result
def test_show_flag_present(self, main_module, tmp_path):
mock_result = self._make_mock_result(b"")
output = tmp_path / "out.txt"
captured_cmd = []
def fake_run(cmd, **kwargs):
captured_cmd.extend(cmd)
return mock_result
with (
patch("hate_crack.main.subprocess.run", side_effect=fake_run),
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatPotfilePath", ""),
):
main_module._run_hashcat_show("1000", "/tmp/h.txt", str(output))
assert "--show" in captured_cmd
def test_valid_lines_written(self, main_module, tmp_path):
stdout = b"abc123:password\ndeadbeef:hunter2\n"
mock_result = self._make_mock_result(stdout)
output = tmp_path / "out.txt"
with (
patch("hate_crack.main.subprocess.run", return_value=mock_result),
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatPotfilePath", ""),
):
main_module._run_hashcat_show("1000", "/tmp/h.txt", str(output))
lines = output.read_text().splitlines()
assert "abc123:password" in lines
assert "deadbeef:hunter2" in lines
def test_hash_parsing_error_excluded(self, main_module, tmp_path):
stdout = b"abc123:password\nHash parsing error: bad line\n"
mock_result = self._make_mock_result(stdout)
output = tmp_path / "out.txt"
with (
patch("hate_crack.main.subprocess.run", return_value=mock_result),
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatPotfilePath", ""),
):
main_module._run_hashcat_show("1000", "/tmp/h.txt", str(output))
content = output.read_text()
assert "Hash parsing error" not in content
assert "abc123:password" in content
def test_star_prefix_excluded(self, main_module, tmp_path):
stdout = b"abc123:password\n* Device #1: ...\n"
mock_result = self._make_mock_result(stdout)
output = tmp_path / "out.txt"
with (
patch("hate_crack.main.subprocess.run", return_value=mock_result),
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatPotfilePath", ""),
):
main_module._run_hashcat_show("1000", "/tmp/h.txt", str(output))
content = output.read_text()
assert "* Device" not in content
def test_lines_without_colon_excluded(self, main_module, tmp_path):
stdout = b"abc123:password\nlinewithoutseparator\n"
mock_result = self._make_mock_result(stdout)
output = tmp_path / "out.txt"
with (
patch("hate_crack.main.subprocess.run", return_value=mock_result),
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatPotfilePath", ""),
):
main_module._run_hashcat_show("1000", "/tmp/h.txt", str(output))
content = output.read_text()
assert "linewithoutseparator" not in content
assert "abc123:password" in content
def test_potfile_path_included_when_set(self, main_module, tmp_path):
mock_result = self._make_mock_result(b"")
output = tmp_path / "out.txt"
captured_cmd = []
def fake_run(cmd, **kwargs):
captured_cmd.extend(cmd)
return mock_result
with (
patch("hate_crack.main.subprocess.run", side_effect=fake_run),
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatPotfilePath", "/my/potfile"),
):
main_module._run_hashcat_show("1000", "/tmp/h.txt", str(output))
assert any("--potfile-path=/my/potfile" in arg for arg in captured_cmd)
class TestDedupNetntlmByUsername:
def test_no_duplicates_no_output_file(self, tmp_path):
input_file = tmp_path / "hashes.txt"
input_file.write_text("user1::domain:challenge:response:blob\nuser2::domain:challenge:response:blob\n")
output_file = tmp_path / "deduped.txt"
total, dupes = _dedup_netntlm_by_username(str(input_file), str(output_file))
assert total == 2
assert dupes == 0
assert not output_file.exists()
def test_duplicates_removed(self, tmp_path):
input_file = tmp_path / "hashes.txt"
input_file.write_text(
"alice::domain:aaa:bbb:ccc\n"
"bob::domain:ddd:eee:fff\n"
"alice::domain:111:222:333\n"
)
output_file = tmp_path / "deduped.txt"
total, dupes = _dedup_netntlm_by_username(str(input_file), str(output_file))
assert total == 3
assert dupes == 1
assert output_file.exists()
lines = output_file.read_text().splitlines()
assert len(lines) == 2
assert any("alice" in line for line in lines)
assert any("bob" in line for line in lines)
def test_only_first_occurrence_kept(self, tmp_path):
input_file = tmp_path / "hashes.txt"
input_file.write_text(
"alice::domain:first:aaa:bbb\n"
"alice::domain:second:ccc:ddd\n"
)
output_file = tmp_path / "deduped.txt"
_dedup_netntlm_by_username(str(input_file), str(output_file))
content = output_file.read_text()
assert "first" in content
assert "second" not in content
def test_empty_file(self, tmp_path):
input_file = tmp_path / "empty.txt"
input_file.write_text("")
output_file = tmp_path / "deduped.txt"
total, dupes = _dedup_netntlm_by_username(str(input_file), str(output_file))
assert total == 0
assert dupes == 0
assert not output_file.exists()
def test_missing_input_file(self, tmp_path):
input_file = tmp_path / "nonexistent.txt"
output_file = tmp_path / "deduped.txt"
total, dupes = _dedup_netntlm_by_username(str(input_file), str(output_file))
assert total == 0
assert dupes == 0
def test_lines_without_delimiter(self, tmp_path):
input_file = tmp_path / "hashes.txt"
input_file.write_text("nodeilimiter\nnodeilimiter\n")
output_file = tmp_path / "deduped.txt"
# Should not raise; whole line treated as username
total, dupes = _dedup_netntlm_by_username(str(input_file), str(output_file))
assert total == 2
assert dupes == 1
def test_case_insensitive_username_dedup(self, tmp_path):
input_file = tmp_path / "hashes.txt"
input_file.write_text("Alice::domain:aaa:bbb:ccc\nalice::domain:ddd:eee:fff\n")
output_file = tmp_path / "deduped.txt"
total, dupes = _dedup_netntlm_by_username(str(input_file), str(output_file))
assert total == 2
assert dupes == 1
class TestResolveWordlistPath:
def test_absolute_existing_file(self, main_module, tmp_path):
wordlist = tmp_path / "words.txt"
wordlist.write_text("word1\nword2\n")
result = main_module._resolve_wordlist_path(str(wordlist), str(tmp_path))
assert result == str(wordlist)
def test_relative_found_in_base_dir(self, main_module, tmp_path):
wordlist = tmp_path / "words.txt"
wordlist.write_text("word1\nword2\n")
result = main_module._resolve_wordlist_path("words.txt", str(tmp_path))
assert result == str(wordlist)
def test_not_found_returns_path_anyway(self, main_module, tmp_path):
# When file not found, returns abspath of first candidate - does not raise
result = main_module._resolve_wordlist_path("missing.txt", str(tmp_path))
assert isinstance(result, str)
assert len(result) > 0
def test_empty_string_returns_empty(self, main_module):
result = main_module._resolve_wordlist_path("", "/some/dir")
assert result == ""
def test_none_returns_none(self, main_module):
result = main_module._resolve_wordlist_path(None, "/some/dir")
assert result is None
class TestGetRulePath:
def test_found_in_rules_directory(self, main_module, tmp_path):
rules_dir = tmp_path / "rules"
rules_dir.mkdir()
rule_file = rules_dir / "best64.rule"
rule_file.write_text("rule content")
with patch.object(main_module, "rulesDirectory", str(rules_dir)):
result = main_module.get_rule_path("best64.rule")
assert result == str(rule_file)
def test_found_in_fallback_dir(self, main_module, tmp_path):
# rulesDirectory has no such file, fallback does
empty_rules_dir = tmp_path / "empty_rules"
empty_rules_dir.mkdir()
fallback_dir = tmp_path / "fallback"
fallback_dir.mkdir()
rule_file = fallback_dir / "custom.rule"
rule_file.write_text("rule content")
with patch.object(main_module, "rulesDirectory", str(empty_rules_dir)):
result = main_module.get_rule_path("custom.rule", fallback_dir=str(fallback_dir))
assert result == str(rule_file)
def test_not_found_returns_first_candidate(self, main_module, tmp_path):
rules_dir = tmp_path / "rules"
rules_dir.mkdir()
with patch.object(main_module, "rulesDirectory", str(rules_dir)):
result = main_module.get_rule_path("nonexistent.rule")
assert result == str(rules_dir / "nonexistent.rule")
def test_no_rules_directory_no_fallback_returns_rule_name(self, main_module):
with patch.object(main_module, "rulesDirectory", ""):
result = main_module.get_rule_path("some.rule")
assert result == "some.rule"
def test_fallback_checked_after_rules_directory(self, main_module, tmp_path):
# Both directories have the rule; rules_directory takes priority
rules_dir = tmp_path / "rules"
rules_dir.mkdir()
fallback_dir = tmp_path / "fallback"
fallback_dir.mkdir()
(rules_dir / "priority.rule").write_text("rules version")
(fallback_dir / "priority.rule").write_text("fallback version")
with patch.object(main_module, "rulesDirectory", str(rules_dir)):
result = main_module.get_rule_path("priority.rule", fallback_dir=str(fallback_dir))
assert result == str(rules_dir / "priority.rule")
class TestCleanupWordlistArtifacts:
def test_removes_out_files_from_cwd(self, main_module, tmp_path):
artifact = tmp_path / "cracked.out"
artifact.write_text("cracked passwords")
with (
patch("os.getcwd", return_value=str(tmp_path)),
patch.object(main_module, "hate_path", str(tmp_path)),
patch.object(main_module, "hcatWordlists", str(tmp_path / "wordlists")),
):
main_module.cleanup_wordlist_artifacts()
assert not artifact.exists()
def test_preserves_non_artifact_files(self, main_module, tmp_path):
keeper = tmp_path / "important.txt"
keeper.write_text("keep me")
artifact = tmp_path / "remove.out"
artifact.write_text("remove me")
with (
patch("os.getcwd", return_value=str(tmp_path)),
patch.object(main_module, "hate_path", str(tmp_path)),
patch.object(main_module, "hcatWordlists", str(tmp_path / "wordlists")),
):
main_module.cleanup_wordlist_artifacts()
assert keeper.exists()
assert not artifact.exists()
def test_removes_out_files_from_hate_path(self, main_module, tmp_path):
hate_dir = tmp_path / "hate_crack"
hate_dir.mkdir()
cwd_dir = tmp_path / "cwd"
cwd_dir.mkdir()
artifact = hate_dir / "session.out"
artifact.write_text("output")
with (
patch("os.getcwd", return_value=str(cwd_dir)),
patch.object(main_module, "hate_path", str(hate_dir)),
patch.object(main_module, "hcatWordlists", str(tmp_path / "wordlists")),
):
main_module.cleanup_wordlist_artifacts()
assert not artifact.exists()
def test_missing_directory_does_not_raise(self, main_module, tmp_path):
nonexistent = tmp_path / "nonexistent"
cwd_dir = tmp_path / "cwd"
cwd_dir.mkdir()
with (
patch("os.getcwd", return_value=str(cwd_dir)),
patch.object(main_module, "hate_path", str(nonexistent)),
patch.object(main_module, "hcatWordlists", str(tmp_path / "wordlists")),
):
# Should not raise even when directories don't exist
main_module.cleanup_wordlist_artifacts()
class TestListWordlistFiles:
@pytest.mark.parametrize(
"filename, included",
[
("rockyou.txt", True),
("wordlist.lst", True),
("custom.dict", True),
("compressed.gz", True),
("archive.7z", False),
("file.torrent", False),
("hashes.out", False),
(".DS_Store", False),
],
)
def test_filters_excluded_extensions(self, tmp_path, main_module, filename, included):
(tmp_path / filename).touch()
result = main_module.list_wordlist_files(str(tmp_path))
if included:
assert filename in result
else:
assert filename not in result
def test_returns_sorted(self, tmp_path, main_module):
for name in ["zebra.txt", "alpha.txt", "middle.txt"]:
(tmp_path / name).touch()
result = main_module.list_wordlist_files(str(tmp_path))
assert result == ["alpha.txt", "middle.txt", "zebra.txt"]
class TestOptimizedKernel:
@pytest.mark.parametrize(
"attack_name",
[
"hcatDictionary",
"hcatQuickDictionary",
"hcatFingerprint",
"hcatCombination",
"hcatCombinator3",
"hcatCombinatorX",
"hcatHybrid",
"hcatYoloCombination",
"hcatMiddleCombinator",
"hcatThoroughCombinator",
"hcatCombipow",
"hcatPrince",
"hcatPermute",
"hcatBandrel",
"hcatGoodMeasure",
"hcatRecycle",
"hcatBruteForce",
"hcatTopMask",
"hcatPathwellBruteForce",
],
)
def test_optimized_attacks_return_true(self, main_module, attack_name):
assert main_module._should_use_optimized_kernel(attack_name) is True
@pytest.mark.parametrize(
"attack_name",
[
"hcatOmen",
"hcatLMtoNT",
],
)
def test_non_optimized_attacks_return_false(self, main_module, attack_name):
assert main_module._should_use_optimized_kernel(attack_name) is False
def test_insert_optimized_flag_adds_O(self, main_module):
cmd = ["hashcat", "-m", "1000"]
main_module._insert_optimized_flag(cmd)
assert "-O" in cmd
def test_insert_optimized_flag_no_duplicate(self, main_module):
cmd = ["hashcat", "-m", "1000", "-O"]
main_module._insert_optimized_flag(cmd)
assert cmd.count("-O") == 1
def test_insert_optimized_flag_respects_long_form(self, main_module):
cmd = ["hashcat", "-m", "1000", "--optimized-kernel-enable"]
main_module._insert_optimized_flag(cmd)
assert "-O" not in cmd
def test_config_override(self, main_module):
original = main_module._optimized_kernel_attacks
try:
main_module._optimized_kernel_attacks = frozenset({"hcatCustom"})
assert main_module._should_use_optimized_kernel("hcatCustom") is True
assert main_module._should_use_optimized_kernel("hcatBruteForce") is False
finally:
main_module._optimized_kernel_attacks = original