test: add 150 tests for attacks, wrappers, utils, api, and proxy

- tests/test_attacks_behavior.py: 29 tests for attack handler logic
  (loopback, extensive, top_mask, combinator, hybrid, ollama, simple pass-throughs)
- tests/test_hashcat_wrappers.py: 33 tests for hashcat subprocess wrappers
  (brute force, quick dict, combination, hybrid, prince, recycle, good measure, etc.)
- tests/test_main_utils.py: 44 tests for utility functions
  (_append_potfile_arg, generate_session_id, _ensure_hashfile_in_cwd,
  _run_hashcat_show, _dedup_netntlm_by_username, path resolution, cleanup)
- tests/test_api_downloads.py: 25 tests for api.py functions
  (sanitize_filename, check_7z, potfile config, hashmob key, extract_with_7z, download)
- tests/test_proxy.py: 18 tests for root module proxy mechanism
  (__getattr__, _sync_globals_to_main, _sync_callables_to_main, symbol re-export)

Also fix combinator_crack to abort gracefully when hcatCombinationWordlist is
a single string (only 1 wordlist configured) instead of crashing with IndexError.
This commit is contained in:
Justin Bollinger
2026-03-02 17:16:54 -05:00
parent c60668fb06
commit 978a24a7c2
6 changed files with 1830 additions and 0 deletions

View File

@@ -389,6 +389,12 @@ def combinator_crack(ctx: Any) -> None:
ctx._resolve_wordlist_path(wl, ctx.hcatWordlists) for wl in wordlists[:2]
]
if len(wordlists) < 2:
print("\n[!] Combinator attack requires 2 wordlists but only 1 is configured.")
print("Set hcatCombinationWordlist to a list of 2 paths in config.json.")
print("Aborting combinator attack.")
return
print("\nStarting combinator attack with 2 wordlists:")
print(f" Wordlist 1: {wordlists[0]}")
print(f" Wordlist 2: {wordlists[1]}")

227
tests/test_api_downloads.py Normal file
View File

@@ -0,0 +1,227 @@
import json
import os
from unittest.mock import MagicMock, patch
from hate_crack.api import (
check_7z,
check_transmission_cli,
download_hashmob_wordlist,
extract_with_7z,
get_hashmob_api_key,
get_hcat_potfile_args,
get_hcat_potfile_path,
sanitize_filename,
)
class TestSanitizeFilename:
def test_normal_filename_unchanged(self):
assert sanitize_filename("rockyou.txt") == "rockyou.txt"
def test_spaces_become_underscores(self):
assert sanitize_filename("my file.txt") == "my_file.txt"
def test_path_separators_removed(self):
# Dots are kept; slashes are removed. "../../etc/passwd" has 4 dots, 2 slashes.
assert sanitize_filename("../../etc/passwd") == "....etcpasswd"
def test_empty_string(self):
assert sanitize_filename("") == ""
def test_mixed_case_preserved(self):
assert sanitize_filename("RockYou.txt") == "RockYou.txt"
class TestCheck7z:
def test_returns_true_when_found(self, capsys):
with patch("shutil.which", return_value="/usr/bin/7z"):
result = check_7z()
assert result is True
def test_returns_false_when_missing(self, capsys):
with patch("shutil.which", return_value=None):
result = check_7z()
assert result is False
captured = capsys.readouterr()
assert "7z" in captured.out
class TestCheckTransmissionCli:
def test_returns_true_when_found(self):
with patch("shutil.which", return_value="/usr/bin/transmission-cli"):
result = check_transmission_cli()
assert result is True
def test_returns_false_when_missing(self, capsys):
with patch("shutil.which", return_value=None):
result = check_transmission_cli()
assert result is False
captured = capsys.readouterr()
assert "transmission-cli" in captured.out
class TestGetHcatPotfilePath:
def test_returns_config_value_when_set(self, tmp_path):
config_data = {"hcatPotfilePath": "/custom/hashcat.potfile"}
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps(config_data))
with patch("hate_crack.api._resolve_config_path", return_value=str(config_file)):
result = get_hcat_potfile_path()
assert result == "/custom/hashcat.potfile"
def test_returns_default_when_key_missing(self, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({}))
with patch("hate_crack.api._resolve_config_path", return_value=str(config_file)):
result = get_hcat_potfile_path()
assert result == os.path.expanduser("~/.hashcat/hashcat.potfile")
def test_returns_default_when_no_config(self):
with patch("hate_crack.api._resolve_config_path", return_value=None):
result = get_hcat_potfile_path()
assert result == os.path.expanduser("~/.hashcat/hashcat.potfile")
def test_expands_tilde_in_config_value(self, tmp_path):
config_data = {"hcatPotfilePath": "~/.custom/hashcat.potfile"}
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps(config_data))
with patch("hate_crack.api._resolve_config_path", return_value=str(config_file)):
result = get_hcat_potfile_path()
assert result == os.path.expanduser("~/.custom/hashcat.potfile")
assert "~" not in result
class TestGetHcatPotfileArgs:
def test_returns_list_with_potfile_arg(self):
with patch("hate_crack.api.get_hcat_potfile_path", return_value="/some/path/hashcat.potfile"):
result = get_hcat_potfile_args()
assert result == ["--potfile-path=/some/path/hashcat.potfile"]
def test_returns_non_empty_list_by_default(self):
# Default path always resolves to something (expanduser never returns empty)
with patch("hate_crack.api._resolve_config_path", return_value=None):
result = get_hcat_potfile_args()
assert len(result) == 1
assert result[0].startswith("--potfile-path=")
class TestGetHashmobApiKey:
def test_returns_key_from_config(self, tmp_path):
config_data = {"hashmob_api_key": "abc123secret"}
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps(config_data))
config_path = str(config_file)
# Patch isfile so the function sees our config file as the pkg_dir config,
# and patch open so reads come from it.
with patch("hate_crack.api.os.path.isfile", side_effect=lambda p: p == config_path), \
patch("hate_crack.api.os.path.dirname", return_value=str(tmp_path)), \
patch("hate_crack.api.os.path.abspath", side_effect=lambda p: p):
result = get_hashmob_api_key()
assert result == "abc123secret"
def test_returns_none_when_missing(self, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({}))
config_path = str(config_file)
with patch("hate_crack.api.os.path.isfile", side_effect=lambda p: p == config_path), \
patch("hate_crack.api.os.path.dirname", return_value=str(tmp_path)), \
patch("hate_crack.api.os.path.abspath", side_effect=lambda p: p):
result = get_hashmob_api_key()
assert result is None
def test_returns_none_when_no_config(self):
with patch("hate_crack.api.os.path.isfile", return_value=False):
result = get_hashmob_api_key()
assert result is None
class TestExtractWith7z:
def _make_run_result(self, returncode=0):
result = MagicMock()
result.returncode = returncode
result.stdout = ""
result.stderr = ""
return result
def test_returns_false_when_not_installed(self, tmp_path, capsys):
with patch("hate_crack.api.shutil.which", return_value=None):
archive = tmp_path / "test.7z"
archive.write_text("fake archive data")
result = extract_with_7z(str(archive), str(tmp_path))
assert result is False
captured = capsys.readouterr()
assert "7z" in captured.out
def test_returns_true_on_success(self, tmp_path):
archive = tmp_path / "test.7z"
archive.write_text("fake archive data")
mock_result = self._make_run_result(returncode=0)
with patch("hate_crack.api.shutil.which", return_value="/usr/bin/7z"), \
patch("subprocess.run", return_value=mock_result):
result = extract_with_7z(str(archive), str(tmp_path), remove_archive=False)
assert result is True
def test_returns_false_on_failure(self, tmp_path):
archive = tmp_path / "test.7z"
archive.write_text("fake archive data")
mock_result = self._make_run_result(returncode=1)
with patch("hate_crack.api.shutil.which", return_value="/usr/bin/7z"), \
patch("subprocess.run", return_value=mock_result):
result = extract_with_7z(str(archive), str(tmp_path))
assert result is False
def test_removes_archive_on_success(self, tmp_path):
archive = tmp_path / "test.7z"
archive.write_text("fake archive data")
mock_result = self._make_run_result(returncode=0)
with patch("hate_crack.api.shutil.which", return_value="/usr/bin/7z"), \
patch("subprocess.run", return_value=mock_result):
result = extract_with_7z(str(archive), str(tmp_path), remove_archive=True)
assert result is True
assert not archive.exists()
def test_keeps_archive_when_remove_false(self, tmp_path):
archive = tmp_path / "test.7z"
archive.write_text("fake archive data")
mock_result = self._make_run_result(returncode=0)
with patch("hate_crack.api.shutil.which", return_value="/usr/bin/7z"), \
patch("subprocess.run", return_value=mock_result):
result = extract_with_7z(str(archive), str(tmp_path), remove_archive=False)
assert result is True
assert archive.exists()
class TestDownloadHashmobWordlist:
def _make_mock_response(self, status_code=200, content=b"wordlist data"):
mock_response = MagicMock()
mock_response.__enter__ = lambda s: mock_response
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.status_code = status_code
mock_response.headers = {"Content-Type": "application/octet-stream"}
mock_response.iter_content.return_value = [content]
mock_response.raise_for_status = MagicMock()
return mock_response
def test_successful_download(self, tmp_path):
mock_response = self._make_mock_response(status_code=200, content=b"wordlist data")
out = tmp_path / "test.txt"
with patch("hate_crack.api.requests.get", return_value=mock_response), \
patch("hate_crack.api.time.sleep"):
result = download_hashmob_wordlist("test.txt", str(out))
assert result is True
assert out.exists()
assert out.read_bytes() == b"wordlist data"
def test_404_returns_false(self, tmp_path):
import requests as req
mock_response = self._make_mock_response(status_code=404)
mock_response.raise_for_status.side_effect = req.exceptions.HTTPError(
response=MagicMock(status_code=404)
)
out = tmp_path / "test.txt"
with patch("hate_crack.api.requests.get", return_value=mock_response), \
patch("hate_crack.api.time.sleep"):
result = download_hashmob_wordlist("test.txt", str(out))
assert result is False

View File

@@ -0,0 +1,368 @@
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
from hate_crack.attacks import (
bandrel_method,
combinator_crack,
extensive_crack,
hybrid_crack,
loopback_attack,
middle_combinator,
ollama_attack,
pathwell_crack,
prince_attack,
thorough_combinator,
top_mask_crack,
yolo_combination,
)
def _make_ctx(hash_type: str = "1000", hash_file: str = "/tmp/hashes.txt") -> MagicMock:
ctx = MagicMock()
ctx.hcatHashType = hash_type
ctx.hcatHashFile = hash_file
return ctx
class TestLoopbackAttack:
def test_no_rules_proceeds_without_rules(self, tmp_path: Path) -> None:
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path / "wordlists")
ctx.rulesDirectory = str(tmp_path / "rules")
os.makedirs(ctx.rulesDirectory, exist_ok=True)
# No rule files in directory -> prompts for download -> user says "n"
# Then rule_choice becomes ["0"] via the "no rules" branch
with (
patch("hate_crack.attacks.download_hashmob_rules"),
patch("builtins.input", side_effect=["n", "0"]),
):
loopback_attack(ctx)
ctx.hcatQuickDictionary.assert_called_once()
call_kwargs = ctx.hcatQuickDictionary.call_args
assert call_kwargs.kwargs.get("loopback") is True
def test_with_rule_file_calls_with_rule(self, tmp_path: Path) -> None:
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path / "wordlists")
ctx.rulesDirectory = str(tmp_path / "rules")
rules_dir = tmp_path / "rules"
rules_dir.mkdir()
(rules_dir / "best66.rule").write_text("")
with patch("builtins.input", return_value="1"):
loopback_attack(ctx)
ctx.hcatQuickDictionary.assert_called_once()
call_args = ctx.hcatQuickDictionary.call_args
assert call_args.kwargs.get("loopback") is True
# Third positional arg is the rule chain string
assert "best66.rule" in call_args[0][2]
def test_rule_99_returns_without_calling(self, tmp_path: Path) -> None:
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path / "wordlists")
ctx.rulesDirectory = str(tmp_path / "rules")
rules_dir = tmp_path / "rules"
rules_dir.mkdir()
(rules_dir / "best66.rule").write_text("")
with patch("builtins.input", return_value="99"):
loopback_attack(ctx)
ctx.hcatQuickDictionary.assert_not_called()
def test_creates_empty_wordlist_if_missing(self, tmp_path: Path) -> None:
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path / "wordlists")
ctx.rulesDirectory = str(tmp_path / "rules")
rules_dir = tmp_path / "rules"
rules_dir.mkdir()
(rules_dir / "best66.rule").write_text("")
empty_txt = tmp_path / "wordlists" / "empty.txt"
assert not empty_txt.exists()
with patch("builtins.input", return_value="1"):
loopback_attack(ctx)
assert empty_txt.exists()
def test_empty_wordlist_passed_to_hcatQuickDictionary(
self, tmp_path: Path
) -> None:
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path / "wordlists")
ctx.rulesDirectory = str(tmp_path / "rules")
rules_dir = tmp_path / "rules"
rules_dir.mkdir()
(rules_dir / "best66.rule").write_text("")
with patch("builtins.input", return_value="1"):
loopback_attack(ctx)
call_args = ctx.hcatQuickDictionary.call_args
# Fourth positional arg is the empty wordlist path
empty_wordlist_arg = call_args[0][3]
assert empty_wordlist_arg.endswith("empty.txt")
class TestExtensiveCrack:
def test_calls_all_attack_methods(self) -> None:
ctx = _make_ctx()
extensive_crack(ctx)
ctx.hcatBruteForce.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile, "1", "7")
ctx.hcatDictionary.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile)
ctx.hcatTopMask.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile, 4 * 60 * 60)
ctx.hcatFingerprint.assert_called_once_with(
ctx.hcatHashType, ctx.hcatHashFile, 7, run_hybrid_on_expanded=False
)
ctx.hcatCombination.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile)
ctx.hcatHybrid.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile)
ctx.hcatGoodMeasure.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile)
def test_calls_recycle_after_each_attack(self) -> None:
ctx = _make_ctx()
extensive_crack(ctx)
# extensive_crack calls hcatRecycle after: brute, dictionary, mask,
# fingerprint, combination, hybrid, and once more at the end (hcatExtraCount)
assert ctx.hcatRecycle.call_count == 7
ctx.hcatRecycle.assert_any_call(ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatBruteCount)
ctx.hcatRecycle.assert_any_call(
ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatDictionaryCount
)
ctx.hcatRecycle.assert_any_call(ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatMaskCount)
ctx.hcatRecycle.assert_any_call(
ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatFingerprintCount
)
ctx.hcatRecycle.assert_any_call(
ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatCombinationCount
)
ctx.hcatRecycle.assert_any_call(ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatHybridCount)
ctx.hcatRecycle.assert_any_call(ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatExtraCount)
class TestTopMaskCrack:
def test_default_time_uses_four_hours(self) -> None:
ctx = _make_ctx()
with patch("builtins.input", return_value=""):
top_mask_crack(ctx)
ctx.hcatTopMask.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile, 4 * 60 * 60)
def test_custom_time_converts_hours_to_seconds(self) -> None:
ctx = _make_ctx()
with patch("builtins.input", return_value="2"):
top_mask_crack(ctx)
ctx.hcatTopMask.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile, 2 * 60 * 60)
def test_one_hour_input(self) -> None:
ctx = _make_ctx()
with patch("builtins.input", return_value="1"):
top_mask_crack(ctx)
ctx.hcatTopMask.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile, 1 * 60 * 60)
class TestCombinatorCrack:
def test_default_list_wordlist_calls_hcatCombination(self) -> None:
ctx = _make_ctx()
ctx.hcatCombinationWordlist = ["/wl/rockyou.txt", "/wl/passwords.txt"]
ctx._resolve_wordlist_path.side_effect = lambda wl, _: wl
with patch("builtins.input", return_value=""):
combinator_crack(ctx)
ctx.hcatCombination.assert_called_once_with(
ctx.hcatHashType,
ctx.hcatHashFile,
["/wl/rockyou.txt", "/wl/passwords.txt"],
)
def test_default_single_string_wordlist_aborts_gracefully(self, capsys) -> None:
# When hcatCombinationWordlist is a plain string (one wordlist), the code
# wraps it in a list giving only 1 item - the handler should abort with a
# clear message instead of crashing with IndexError.
ctx = _make_ctx()
ctx.hcatCombinationWordlist = "/wl/rockyou.txt"
ctx._resolve_wordlist_path.side_effect = lambda wl, _: wl
with patch("builtins.input", return_value="y"):
combinator_crack(ctx)
ctx.hcatCombination.assert_not_called()
captured = capsys.readouterr()
assert "Aborting combinator attack" in captured.out
def test_resolve_wordlist_path_called_for_each_wordlist(self) -> None:
ctx = _make_ctx()
ctx.hcatCombinationWordlist = ["/wl/a.txt", "/wl/b.txt"]
ctx._resolve_wordlist_path.side_effect = lambda wl, _: wl
with patch("builtins.input", return_value=""):
combinator_crack(ctx)
assert ctx._resolve_wordlist_path.call_count == 2
ctx._resolve_wordlist_path.assert_any_call("/wl/a.txt", ctx.hcatWordlists)
ctx._resolve_wordlist_path.assert_any_call("/wl/b.txt", ctx.hcatWordlists)
def test_uses_only_first_two_wordlists(self) -> None:
ctx = _make_ctx()
ctx.hcatCombinationWordlist = ["/wl/a.txt", "/wl/b.txt", "/wl/c.txt"]
ctx._resolve_wordlist_path.side_effect = lambda wl, _: wl
with patch("builtins.input", return_value=""):
combinator_crack(ctx)
call_wordlists = ctx.hcatCombination.call_args[0][2]
assert len(call_wordlists) == 2
assert "/wl/c.txt" not in call_wordlists
class TestHybridCrack:
def test_default_list_wordlist_calls_hcatHybrid(self) -> None:
ctx = _make_ctx()
ctx.hcatHybridlist = ["/wl/rockyou.txt"]
ctx._resolve_wordlist_path.side_effect = lambda wl, _: wl
with patch("builtins.input", return_value=""):
hybrid_crack(ctx)
ctx.hcatHybrid.assert_called_once_with(
ctx.hcatHashType,
ctx.hcatHashFile,
["/wl/rockyou.txt"],
)
def test_default_string_wordlist_wraps_in_list(self) -> None:
ctx = _make_ctx()
ctx.hcatHybridlist = "/wl/rockyou.txt"
ctx._resolve_wordlist_path.side_effect = lambda wl, _: wl
with patch("builtins.input", return_value=""):
hybrid_crack(ctx)
ctx.hcatHybrid.assert_called_once()
call_wordlists = ctx.hcatHybrid.call_args[0][2]
assert "/wl/rockyou.txt" in call_wordlists
def test_decline_default_aborts_when_no_selection(self) -> None:
ctx = _make_ctx()
ctx.select_file_with_autocomplete.return_value = None
with patch("builtins.input", return_value="n"):
hybrid_crack(ctx)
ctx.hcatHybrid.assert_not_called()
class TestSimpleAttacks:
def test_pathwell_crack(self) -> None:
ctx = _make_ctx()
pathwell_crack(ctx)
ctx.hcatPathwellBruteForce.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile)
def test_prince_attack(self) -> None:
ctx = _make_ctx()
prince_attack(ctx)
ctx.hcatPrince.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile)
def test_yolo_combination(self) -> None:
ctx = _make_ctx()
yolo_combination(ctx)
ctx.hcatYoloCombination.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile)
def test_thorough_combinator(self) -> None:
ctx = _make_ctx()
thorough_combinator(ctx)
ctx.hcatThoroughCombinator.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile)
def test_middle_combinator(self) -> None:
ctx = _make_ctx()
middle_combinator(ctx)
ctx.hcatMiddleCombinator.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile)
def test_bandrel_method(self) -> None:
ctx = _make_ctx()
bandrel_method(ctx)
ctx.hcatBandrel.assert_called_once_with(ctx.hcatHashType, ctx.hcatHashFile)
def test_pathwell_crack_passes_hash_type_and_file(self) -> None:
ctx = _make_ctx(hash_type="500", hash_file="/data/hashes.hash")
pathwell_crack(ctx)
ctx.hcatPathwellBruteForce.assert_called_once_with("500", "/data/hashes.hash")
def test_prince_attack_passes_hash_type_and_file(self) -> None:
ctx = _make_ctx(hash_type="500", hash_file="/data/hashes.hash")
prince_attack(ctx)
ctx.hcatPrince.assert_called_once_with("500", "/data/hashes.hash")
class TestOllamaAttack:
def test_calls_hcatOllama_with_context(self) -> None:
ctx = _make_ctx()
with patch("builtins.input", side_effect=["ACME", "tech", "NYC"]):
ollama_attack(ctx)
ctx.hcatOllama.assert_called_once_with(
ctx.hcatHashType,
ctx.hcatHashFile,
"target",
{"company": "ACME", "industry": "tech", "location": "NYC"},
)
def test_passes_hash_type_and_file(self) -> None:
ctx = _make_ctx(hash_type="1800", hash_file="/tmp/sha512.txt")
with patch("builtins.input", side_effect=["Corp", "finance", "London"]):
ollama_attack(ctx)
call_args = ctx.hcatOllama.call_args[0]
assert call_args[0] == "1800"
assert call_args[1] == "/tmp/sha512.txt"
def test_strips_whitespace_from_inputs(self) -> None:
ctx = _make_ctx()
with patch("builtins.input", side_effect=[" ACME ", " tech ", " NYC "]):
ollama_attack(ctx)
target_info = ctx.hcatOllama.call_args[0][3]
assert target_info["company"] == "ACME"
assert target_info["industry"] == "tech"
assert target_info["location"] == "NYC"
def test_target_string_is_literal_target(self) -> None:
ctx = _make_ctx()
with patch("builtins.input", side_effect=["X", "Y", "Z"]):
ollama_attack(ctx)
assert ctx.hcatOllama.call_args[0][2] == "target"

View File

@@ -0,0 +1,624 @@
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture
def main_module(hc_module):
"""Return the underlying hate_crack.main module for direct patching."""
return hc_module._main
def _make_mock_proc(wait_side_effect=None):
proc = MagicMock()
if wait_side_effect is not None:
proc.wait.side_effect = wait_side_effect
else:
proc.wait.return_value = None
proc.pid = 12345
return proc
def _common_patches(main_module, tmp_path):
"""Return a list of patch context managers for the most common globals."""
hash_file = str(tmp_path / "hashes.txt")
return [
patch.object(main_module, "hcatBin", "hashcat"),
patch.object(main_module, "hcatTuning", ""),
patch.object(main_module, "hcatPotfilePath", ""),
patch.object(main_module, "hcatHashFile", hash_file, create=True),
patch.object(main_module, "generate_session_id", return_value="test_session"),
]
class TestHcatBruteForce:
def test_contains_attack_mode_3(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \
patch.object(main_module, "lineCount", return_value=0):
main_module.hcatBruteForce("1000", hash_file, 1, 7)
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert "-a" in cmd
assert "3" in cmd
def test_increment_flags(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \
patch.object(main_module, "lineCount", return_value=0):
main_module.hcatBruteForce("1000", hash_file, 3, 9)
cmd = mock_popen.call_args[0][0]
assert "--increment" in cmd
assert "--increment-min=3" in cmd
assert "--increment-max=9" in cmd
def test_keyboard_interrupt_kills_process(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc(wait_side_effect=KeyboardInterrupt())
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc), \
patch.object(main_module, "lineCount", return_value=0):
main_module.hcatBruteForce("1000", hash_file, 1, 7)
mock_proc.kill.assert_called_once()
def test_hash_type_and_file_in_cmd(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \
patch.object(main_module, "lineCount", return_value=0):
main_module.hcatBruteForce("500", hash_file, 1, 8)
cmd = mock_popen.call_args[0][0]
assert "-m" in cmd
assert "500" in cmd
assert hash_file in cmd
class TestHcatQuickDictionary:
def test_wordlist_added_to_cmd(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wordlist = str(tmp_path / "words.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \
patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \
patch("hate_crack.main._debug_cmd"):
main_module.hcatQuickDictionary("1000", hash_file, "", wordlist)
cmd = mock_popen.call_args[0][0]
assert wordlist in cmd
def test_loopback_flag_added(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wordlist = str(tmp_path / "words.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \
patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \
patch("hate_crack.main._debug_cmd"):
main_module.hcatQuickDictionary("1000", hash_file, "", wordlist, loopback=True)
cmd = mock_popen.call_args[0][0]
assert "--loopback" in cmd
def test_no_loopback_by_default(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wordlist = str(tmp_path / "words.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \
patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \
patch("hate_crack.main._debug_cmd"):
main_module.hcatQuickDictionary("1000", hash_file, "", wordlist)
cmd = mock_popen.call_args[0][0]
assert "--loopback" not in cmd
def test_chains_added_when_provided(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wordlist = str(tmp_path / "words.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \
patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \
patch("hate_crack.main._debug_cmd"):
main_module.hcatQuickDictionary(
"1000", hash_file, "-r /fake/rule.rule", wordlist
)
cmd = mock_popen.call_args[0][0]
assert "-r" in cmd
assert "/fake/rule.rule" in cmd
def test_list_of_wordlists_all_added(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wl1 = str(tmp_path / "words1.txt")
wl2 = str(tmp_path / "words2.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \
patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \
patch("hate_crack.main._debug_cmd"):
main_module.hcatQuickDictionary("1000", hash_file, "", [wl1, wl2])
cmd = mock_popen.call_args[0][0]
assert wl1 in cmd
assert wl2 in cmd
class TestHcatCombination:
def test_contains_attack_mode_1(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wl1 = tmp_path / "words1.txt"
wl2 = tmp_path / "words2.txt"
wl1.write_text("word1\n")
wl2.write_text("word2\n")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hcatWordlists", str(tmp_path)), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \
patch.object(main_module, "lineCount", return_value=0), \
patch.object(main_module, "hcatHashCracked", 0, create=True):
main_module.hcatCombination(
"1000", hash_file, wordlists=[str(wl1), str(wl2)]
)
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert "-a" in cmd
assert "1" in cmd
def test_aborts_with_fewer_than_two_wordlists(self, main_module, tmp_path, capsys):
hash_file = str(tmp_path / "hashes.txt")
wl1 = tmp_path / "words1.txt"
wl1.write_text("word1\n")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hcatWordlists", str(tmp_path)), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen:
main_module.hcatCombination("1000", hash_file, wordlists=[str(wl1)])
mock_popen.assert_not_called()
captured = capsys.readouterr()
assert "requires at least 2" in captured.out
class TestHcatHybrid:
def test_contains_attack_mode_6_or_7(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wl = tmp_path / "words.txt"
wl.write_text("word\n")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hcatWordlists", str(tmp_path)), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \
patch.object(main_module, "lineCount", return_value=0), \
patch.object(main_module, "hcatHashCracked", 0, create=True):
main_module.hcatHybrid("1000", hash_file, wordlists=[str(wl)])
assert mock_popen.call_count >= 1
all_cmds = [c[0][0] for c in mock_popen.call_args_list]
modes_used = set()
for cmd in all_cmds:
if "-a" in cmd:
idx = cmd.index("-a")
modes_used.add(cmd[idx + 1])
assert modes_used & {"6", "7"}, f"Expected mode 6 or 7 in cmds, got modes: {modes_used}"
def test_aborts_when_no_valid_wordlists(self, main_module, tmp_path, capsys):
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hcatWordlists", str(tmp_path)), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen:
main_module.hcatHybrid(
"1000", hash_file, wordlists=["/nonexistent/wordlist.txt"]
)
mock_popen.assert_not_called()
captured = capsys.readouterr()
assert "No valid wordlists" in captured.out
class TestHcatPathwellBruteForce:
def test_popen_called(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hate_path", str(tmp_path)), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen:
main_module.hcatPathwellBruteForce("1000", hash_file)
mock_popen.assert_called_once()
def test_attack_mode_3_in_cmd(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hate_path", str(tmp_path)), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen:
main_module.hcatPathwellBruteForce("1000", hash_file)
cmd = mock_popen.call_args[0][0]
assert "-a" in cmd
assert "3" in cmd
def test_pathwell_mask_in_cmd(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hate_path", str(tmp_path)), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen:
main_module.hcatPathwellBruteForce("1000", hash_file)
cmd = mock_popen.call_args[0][0]
assert any("pathwell.hcmask" in arg for arg in cmd)
class TestHcatPrince:
def test_two_popen_calls_for_pipe(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
prince_base = tmp_path / "prince_base.txt"
prince_base.write_text("password\n")
prince_dir = tmp_path / "princeprocessor"
prince_dir.mkdir()
(prince_dir / "pp64.bin").touch()
mock_prince_proc = MagicMock()
mock_prince_proc.stdout = MagicMock()
mock_prince_proc.wait.return_value = None
mock_hashcat_proc = MagicMock()
mock_hashcat_proc.wait.return_value = None
mock_hashcat_proc.pid = 12345
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hate_path", str(tmp_path)), \
patch.object(main_module, "hcatPrinceBin", "pp64.bin"), \
patch.object(main_module, "hcatPrinceBaseList", [str(prince_base)]), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch.object(main_module, "get_rule_path", return_value="/fake/prince_optimized.rule"), \
patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \
patch("hate_crack.main.subprocess.Popen") as mock_popen:
mock_popen.side_effect = [mock_prince_proc, mock_hashcat_proc]
main_module.hcatPrince("1000", hash_file)
assert mock_popen.call_count == 2
def test_prince_binary_first_call(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
prince_base = tmp_path / "prince_base.txt"
prince_base.write_text("password\n")
prince_dir = tmp_path / "princeprocessor"
prince_dir.mkdir()
pp_bin = prince_dir / "pp64.bin"
pp_bin.touch()
mock_prince_proc = MagicMock()
mock_prince_proc.stdout = MagicMock()
mock_prince_proc.wait.return_value = None
mock_hashcat_proc = MagicMock()
mock_hashcat_proc.wait.return_value = None
mock_hashcat_proc.pid = 12345
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hate_path", str(tmp_path)), \
patch.object(main_module, "hcatPrinceBin", "pp64.bin"), \
patch.object(main_module, "hcatPrinceBaseList", [str(prince_base)]), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch.object(main_module, "get_rule_path", return_value="/fake/prince_optimized.rule"), \
patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \
patch("hate_crack.main.subprocess.Popen") as mock_popen:
mock_popen.side_effect = [mock_prince_proc, mock_hashcat_proc]
main_module.hcatPrince("1000", hash_file)
prince_cmd = mock_popen.call_args_list[0][0][0]
assert str(pp_bin) in prince_cmd
def test_aborts_when_prince_base_missing(self, main_module, tmp_path, capsys):
hash_file = str(tmp_path / "hashes.txt")
with patch.object(main_module, "hate_path", str(tmp_path)), \
patch.object(main_module, "hcatPrinceBin", "pp64.bin"), \
patch.object(main_module, "hcatPrinceBaseList", ["/nonexistent/base.txt"]), \
patch.object(main_module, "get_rule_path", return_value="/fake/rule.rule"), \
patch("hate_crack.main.subprocess.Popen") as mock_popen:
main_module.hcatPrince("1000", hash_file)
mock_popen.assert_not_called()
captured = capsys.readouterr()
assert "not found" in captured.out
class TestHcatRecycle:
def test_skipped_when_count_zero(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
with patch("hate_crack.main.subprocess.Popen") as mock_popen:
main_module.hcatRecycle("1000", hash_file, 0)
mock_popen.assert_not_called()
def test_popen_called_when_count_nonzero(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
out_file = tmp_path / "hashes.txt.out"
out_file.write_text("hash1:password1\nhash2:password2\n")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hcatRules", ["best66.rule"]), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch.object(main_module, "get_rule_path", return_value="/fake/best66.rule"), \
patch("hate_crack.main._write_delimited_field"), \
patch("hate_crack.main.convert_hex", return_value=["password1", "password2"]), \
patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \
patch("builtins.open", create=True) as mock_open, \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen:
mock_open.return_value.__enter__ = MagicMock(return_value=MagicMock())
mock_open.return_value.__exit__ = MagicMock(return_value=False)
main_module.hcatRecycle("1000", hash_file, 5)
assert mock_popen.call_count >= 1
def test_rule_path_in_cmd_when_count_nonzero(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hcatRules", ["best66.rule"]), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch.object(main_module, "get_rule_path", return_value="/fake/best66.rule"), \
patch("hate_crack.main._write_delimited_field"), \
patch("hate_crack.main.convert_hex", return_value=["pass1"]), \
patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \
patch("builtins.open", create=True) as mock_open, \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen:
mock_open.return_value.__enter__ = MagicMock(return_value=MagicMock())
mock_open.return_value.__exit__ = MagicMock(return_value=False)
main_module.hcatRecycle("1000", hash_file, 3)
cmd = mock_popen.call_args[0][0]
assert "-r" in cmd
assert "/fake/best66.rule" in cmd
class TestHcatGoodMeasure:
def test_popen_called_at_least_once(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hcatGoodMeasureBaseList", "/fake/base.txt"), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch.object(main_module, "get_rule_path", return_value="/fake/rule.rule"), \
patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \
patch.object(main_module, "lineCount", return_value=0), \
patch.object(main_module, "hcatHashCracked", 0, create=True):
main_module.hcatGoodMeasure("1000", hash_file)
assert mock_popen.call_count >= 1
def test_hash_type_in_cmd(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hcatGoodMeasureBaseList", "/fake/base.txt"), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch.object(main_module, "get_rule_path", return_value="/fake/rule.rule"), \
patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen, \
patch.object(main_module, "lineCount", return_value=0), \
patch.object(main_module, "hcatHashCracked", 0, create=True):
main_module.hcatGoodMeasure("500", hash_file)
cmd = mock_popen.call_args_list[0][0][0]
assert "-m" in cmd
assert "500" in cmd
class TestHcatMiddleCombinator:
def test_popen_called_at_least_once(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hcatMiddleCombinatorMasks", ["!", "1"]), \
patch.object(main_module, "hcatMiddleBaseList", "/fake/base.txt"), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen:
main_module.hcatMiddleCombinator("1000", hash_file)
assert mock_popen.call_count >= 1
def test_attack_mode_1_in_cmd(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hcatMiddleCombinatorMasks", ["!"]), \
patch.object(main_module, "hcatMiddleBaseList", "/fake/base.txt"), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen:
main_module.hcatMiddleCombinator("1000", hash_file)
cmd = mock_popen.call_args_list[0][0][0]
assert "-a" in cmd
assert "1" in cmd
class TestHcatThoroughCombinator:
def test_popen_called_at_least_once(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
mock_proc = _make_mock_proc()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hcatThoroughCombinatorMasks", ["!"]), \
patch.object(main_module, "hcatThoroughBaseList", "/fake/base.txt"), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen:
main_module.hcatThoroughCombinator("1000", hash_file)
assert mock_popen.call_count >= 1
class TestHcatYoloCombination:
def test_popen_called_once_then_keyboard_interrupt(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wl_dir = tmp_path / "wordlists"
wl_dir.mkdir()
(wl_dir / "words.txt").write_text("word\n")
mock_proc = MagicMock()
mock_proc.pid = 12345
mock_proc.wait.side_effect = KeyboardInterrupt()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hcatWordlists", str(wl_dir)), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen:
main_module.hcatYoloCombination("1000", hash_file)
assert mock_popen.call_count >= 1
mock_proc.kill.assert_called()
def test_attack_mode_1_in_cmd(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
wl_dir = tmp_path / "wordlists"
wl_dir.mkdir()
(wl_dir / "words.txt").write_text("word\n")
mock_proc = MagicMock()
mock_proc.pid = 12345
mock_proc.wait.side_effect = KeyboardInterrupt()
with patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "hcatWordlists", str(wl_dir)), \
patch.object(main_module, "generate_session_id", return_value="test_session"), \
patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen:
main_module.hcatYoloCombination("1000", hash_file)
cmd = mock_popen.call_args_list[0][0][0]
assert "-a" in cmd
assert "1" in cmd
class TestAppendPotfileArg:
def test_appends_when_potfile_set(self, main_module):
cmd = ["hashcat", "-m", "1000"]
with patch.object(main_module, "hcatPotfilePath", "/fake/hashcat.pot"):
main_module._append_potfile_arg(cmd)
assert "--potfile-path=/fake/hashcat.pot" in cmd
def test_no_append_when_empty(self, main_module):
cmd = ["hashcat", "-m", "1000"]
with patch.object(main_module, "hcatPotfilePath", ""):
main_module._append_potfile_arg(cmd)
assert not any(arg.startswith("--potfile-path") for arg in cmd)
def test_no_append_when_use_potfile_false(self, main_module):
cmd = ["hashcat", "-m", "1000"]
with patch.object(main_module, "hcatPotfilePath", "/fake/hashcat.pot"):
main_module._append_potfile_arg(cmd, use_potfile_path=False)
assert not any(arg.startswith("--potfile-path") for arg in cmd)
def test_explicit_potfile_path_overrides_global(self, main_module):
cmd = ["hashcat", "-m", "1000"]
with patch.object(main_module, "hcatPotfilePath", "/global/hashcat.pot"):
main_module._append_potfile_arg(cmd, potfile_path="/override/custom.pot")
assert "--potfile-path=/override/custom.pot" in cmd
assert "--potfile-path=/global/hashcat.pot" not in cmd

480
tests/test_main_utils.py Normal file
View File

@@ -0,0 +1,480 @@
"""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):
target = tmp_path / "hashfile.txt"
target.write_text("hashes")
with patch("os.getcwd", return_value=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):
# 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")
with patch("os.getcwd", return_value=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):
# 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()
with patch("os.getcwd", return_value=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)
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()

125
tests/test_proxy.py Normal file
View File

@@ -0,0 +1,125 @@
import pytest
class TestGetAttrProxy:
def test_known_attribute_proxied(self, hc_module):
"""Accessing a known main.py global via hc_module returns main's value."""
val = hc_module._main.debug_mode
assert hc_module.debug_mode == val
def test_nonexistent_attribute_raises(self, hc_module):
"""Accessing a nonexistent attribute raises AttributeError."""
with pytest.raises(AttributeError):
_ = hc_module.this_attribute_does_not_exist_xyz
def test_getattr_returns_main_function(self, hc_module):
"""Functions defined in main.py are accessible via the proxy."""
assert callable(hc_module.generate_session_id)
class TestSyncGlobalsToMain:
@pytest.mark.parametrize(
"name,value",
[
("hcatHashType", "9999"),
("hcatHashFile", "/tmp/synced.txt"),
("hcatHashFileOrig", "/tmp/orig.txt"),
("pipalPath", "/tmp/pipal"),
("pipal_count", 42),
("debug_mode", True),
],
)
def test_syncs_global(self, hc_module, name, value):
"""Setting a synced name in hc_module.__dict__ and calling sync propagates to main."""
hc_module.__dict__[name] = value
hc_module._sync_globals_to_main()
assert getattr(hc_module._main, name) == value
def test_only_syncs_listed_names(self, hc_module):
"""Names not in the sync list are not pushed to main."""
hc_module.__dict__["_random_unlisted_var"] = "should_not_sync"
hc_module._sync_globals_to_main()
assert getattr(hc_module._main, "_random_unlisted_var", None) != "should_not_sync"
def test_absent_name_skipped(self, hc_module):
"""If a synced name is absent from hc_module globals, main is not modified."""
hc_module.__dict__.pop("pipal_count", None)
original = getattr(hc_module._main, "pipal_count", None)
hc_module._sync_globals_to_main()
assert getattr(hc_module._main, "pipal_count", None) == original
class TestSyncCallablesToMain:
def test_syncs_callable_to_main(self, hc_module):
"""A callable set in hc_module.__dict__ is pushed to main."""
def fake_fn():
return "fake"
hc_module.__dict__["quit_hc"] = fake_fn
hc_module._sync_callables_to_main()
assert hc_module._main.quit_hc is fake_fn
def test_syncs_show_results(self, hc_module):
"""show_results callable is pushed to main when present."""
def fake_fn():
return "results"
hc_module.__dict__["show_results"] = fake_fn
hc_module._sync_callables_to_main()
assert hc_module._main.show_results is fake_fn
def test_skips_when_not_in_globals(self, hc_module):
"""If a callable name is absent from hc_module globals, main is unchanged."""
original = getattr(hc_module._main, "show_readme", None)
hc_module.__dict__.pop("show_readme", None)
hc_module._sync_callables_to_main()
assert getattr(hc_module._main, "show_readme", None) == original
def test_all_callable_names_sync(self, hc_module):
"""All callable names in the sync list are pushed when present."""
names = [
"weakpass_wordlist_menu",
"download_hashmob_wordlists",
"download_hashmob_rules",
"hashview_api",
"export_excel",
"show_results",
"show_readme",
"quit_hc",
]
def make_fn(n: str):
def fn() -> str:
return n
return fn
fakes = {name: make_fn(name) for name in names}
for name, fn in fakes.items():
hc_module.__dict__[name] = fn
hc_module._sync_callables_to_main()
for name, fn in fakes.items():
assert getattr(hc_module._main, name) is fn
class TestSymbolReexport:
def test_main_function_accessible(self, hc_module):
"""Functions from main.py should be accessible on hc_module."""
assert callable(getattr(hc_module, "hcatBruteForce", None))
def test_hc_module_has_main_ref(self, hc_module):
"""hc_module._main should be the hate_crack.main module."""
import hate_crack.main as main_mod
assert hc_module._main is main_mod
def test_debug_mode_reexported(self, hc_module):
"""Module-level globals from main.py appear on hc_module at load time."""
assert hasattr(hc_module, "debug_mode")
def test_generate_session_id_reexported(self, hc_module):
"""generate_session_id from main.py is accessible directly on hc_module."""
fn = getattr(hc_module, "generate_session_id", None)
assert callable(fn)