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_three_wordlists_in_config_routes_to_combinator3(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) ctx.hcatCombinator3.assert_called_once() ctx.hcatCombination.assert_not_called() call_wordlists = ctx.hcatCombinator3.call_args[0][2] assert len(call_wordlists) == 3 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("hate_crack.attacks.interactive_menu", return_value="1"), 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("hate_crack.attacks.interactive_menu", return_value="1"), 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("hate_crack.attacks.interactive_menu", return_value="1"), 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("hate_crack.attacks.interactive_menu", return_value="1"), patch("builtins.input", side_effect=["X", "Y", "Z"]), ): ollama_attack(ctx) assert ctx.hcatOllama.call_args[0][2] == "target" def test_wordlist_mode_calls_hcatOllama_with_path(self) -> None: ctx = _make_ctx() ctx.list_wordlist_files.return_value = ["rockyou.txt"] ctx.hcatWordlists = "/tmp/wl" # mode "2" from interactive_menu, then pick wordlist "1" via input with ( patch("hate_crack.attacks.interactive_menu", return_value="2"), patch("builtins.input", side_effect=["1"]), ): ollama_attack(ctx) args = ctx.hcatOllama.call_args[0] assert args[2] == "wordlist" assert args[3].endswith("rockyou.txt") def test_escape_cancels_without_calling_hcatOllama(self) -> None: """None from interactive_menu (Escape / 99) cancels the attack.""" ctx = _make_ctx() with patch("hate_crack.attacks.interactive_menu", return_value=None): ollama_attack(ctx) ctx.hcatOllama.assert_not_called() def test_cancel_key_cancels_without_calling_hcatOllama(self) -> None: """Selecting '99' (Cancel) cancels the attack.""" ctx = _make_ctx() with patch("hate_crack.attacks.interactive_menu", return_value="99"): ollama_attack(ctx) ctx.hcatOllama.assert_not_called() def test_wordlist_mode_aborts_when_no_wordlist_picked(self) -> None: ctx = _make_ctx() # mode "2" from interactive_menu, then user cancels the file picker ctx.list_wordlist_files.return_value = [] with ( patch("hate_crack.attacks.interactive_menu", return_value="2"), patch("builtins.input", side_effect=["q"]), ): ollama_attack(ctx) ctx.hcatOllama.assert_not_called() def test_cracked_mode_offered_when_out_file_has_content( self, tmp_path: Path ) -> None: hash_file = tmp_path / "hashes.txt" hash_file.touch() out_file = tmp_path / "hashes.txt.out" out_file.write_text("hash:Summer2024!\n") ctx = _make_ctx(hash_file=str(hash_file)) captured_items: list[list[tuple[str, str]]] = [] def capture_menu(items, **kwargs): captured_items.append(list(items)) return "3" with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu): ollama_attack(ctx) # Option 3 must be present in the items list when cracked file exists keys = [k for k, _ in captured_items[0]] assert "3" in keys ctx.hcatOllama.assert_called_once_with( ctx.hcatHashType, str(hash_file), "cracked", str(out_file) ) def test_cracked_mode_not_offered_when_out_file_missing( self, tmp_path: Path ) -> None: """Option 3 must NOT appear in items when no cracked file exists.""" hash_file = tmp_path / "hashes.txt" hash_file.touch() ctx = _make_ctx(hash_file=str(hash_file)) captured_items: list[list[tuple[str, str]]] = [] def capture_menu(items, **kwargs): captured_items.append(list(items)) return "99" # cancel with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu): ollama_attack(ctx) keys = [k for k, _ in captured_items[0]] assert "3" not in keys ctx.hcatOllama.assert_not_called() def test_cracked_mode_not_offered_when_out_file_empty( self, tmp_path: Path ) -> None: """Option 3 must NOT appear in items when cracked file exists but is empty.""" hash_file = tmp_path / "hashes.txt" hash_file.touch() (tmp_path / "hashes.txt.out").touch() # exists but zero bytes ctx = _make_ctx(hash_file=str(hash_file)) captured_items: list[list[tuple[str, str]]] = [] def capture_menu(items, **kwargs): captured_items.append(list(items)) return "99" # cancel with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu): ollama_attack(ctx) keys = [k for k, _ in captured_items[0]] assert "3" not in keys ctx.hcatOllama.assert_not_called() def test_target_and_wordlist_modes_unaffected_by_cracked_option( self, tmp_path: Path ) -> None: """Existing modes still work when a cracked file is present.""" hash_file = tmp_path / "hashes.txt" hash_file.touch() (tmp_path / "hashes.txt.out").write_text("hash:Summer2024!\n") ctx = _make_ctx(hash_file=str(hash_file)) with ( patch("hate_crack.attacks.interactive_menu", return_value="1"), patch("builtins.input", side_effect=["ACME", "tech", "NYC"]), ): ollama_attack(ctx) assert ctx.hcatOllama.call_args[0][2] == "target" def test_arrow_menu_env_reaches_ollama_attack(self) -> None: """HATE_CRACK_ARROW_MENU=1 routes through interactive_menu in ollama_attack.""" import os from hate_crack.attacks import interactive_menu as real_im ctx = _make_ctx() calls: list[tuple] = [] def spy_menu(items, **kwargs): calls.append(tuple(items)) return "99" # cancel immediately with ( patch("hate_crack.attacks.interactive_menu", side_effect=spy_menu), ): ollama_attack(ctx) # interactive_menu was called — confirms the arrow-menu code path is reachable assert len(calls) == 1 keys = [k for k, _ in calls[0]] assert "1" in keys assert "2" in keys assert "99" in keys class TestOmenPickTrainingWordlistReprompt: """_pick_training_wordlist re-prompts on invalid input instead of aborting.""" def _make_ctx(self, wordlist_files=None): ctx = MagicMock() ctx.list_wordlist_files.return_value = wordlist_files or ["rockyou.txt"] ctx.hcatWordlists = "/tmp/wl" ctx.hcatHashFile = "/tmp/hashes.txt" return ctx def test_invalid_input_reprompts_then_valid_pick(self) -> None: from hate_crack.attacks import _pick_training_wordlist ctx = self._make_ctx(["rockyou.txt"]) # First input is invalid, second is valid with patch("builtins.input", side_effect=["bad", "1"]): result = _pick_training_wordlist(ctx) assert result is not None assert "rockyou.txt" in result def test_cancel_with_q_returns_none(self) -> None: from hate_crack.attacks import _pick_training_wordlist ctx = self._make_ctx(["rockyou.txt"]) with patch("builtins.input", return_value="q"): result = _pick_training_wordlist(ctx) assert result is None def test_multiple_invalid_inputs_then_cancel(self) -> None: from hate_crack.attacks import _pick_training_wordlist ctx = self._make_ctx(["rockyou.txt"]) with patch("builtins.input", side_effect=["99", "abc", "q"]): result = _pick_training_wordlist(ctx) assert result is None class TestMarkovPickTrainingSourceReprompt: """_markov_pick_training_source re-prompts on invalid input instead of aborting.""" def _make_ctx(self, tmp_path, has_cracked=False, wordlist_files=None): ctx = MagicMock() hash_file = str(tmp_path / "hashes.txt") ctx.hcatHashFile = hash_file ctx.list_wordlist_files.return_value = wordlist_files or ["rockyou.txt"] ctx.hcatWordlists = str(tmp_path / "wordlists") if has_cracked: (tmp_path / "hashes.txt.out").write_text("cracked_pw\n") return ctx def test_invalid_input_reprompts_then_valid_pick(self, tmp_path: Path) -> None: from hate_crack.attacks import _markov_pick_training_source ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"]) with patch("builtins.input", side_effect=["bad", "1"]): result = _markov_pick_training_source(ctx) assert result is not None assert "rockyou.txt" in result def test_cancel_with_q_returns_none(self, tmp_path: Path) -> None: from hate_crack.attacks import _markov_pick_training_source ctx = self._make_ctx(tmp_path) with patch("builtins.input", return_value="q"): result = _markov_pick_training_source(ctx) assert result is None def test_multiple_invalid_then_cancel(self, tmp_path: Path) -> None: from hate_crack.attacks import _markov_pick_training_source ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"]) with patch("builtins.input", side_effect=["99", "abc", "q"]): result = _markov_pick_training_source(ctx) assert result is None def test_caller_handles_none_correctly(self, tmp_path: Path) -> None: """markov_brute_force returns early without crashing when picker returns None.""" from hate_crack.attacks import markov_brute_force ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"]) # No .hcstat2 file → goes straight to picker; user cancels with patch("builtins.input", return_value="q"): markov_brute_force(ctx) ctx.hcatMarkovTrain.assert_not_called() ctx.hcatMarkovBruteForce.assert_not_called()