diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c8f972..faa252e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Dates are omitted for releases predating this file; see the git tags for exact timing. +## [2.14.1] - 2026-07-25 + +### Fixed + +- **Tab completion on custom file-path prompts.** The `p. Enter a custom path` + branches of the OMEN and Markov training pickers, the combipow wordlist + prompt, and the rule cleanup/optimize output-path prompts used a bare + `input()` with no readline completer, so TAB did nothing. They now route + through `select_file_with_autocomplete` for consistent path autocompletion. +- **Stale completer leak.** `select_file_with_autocomplete` and the + `_configure_readline`-based pickers now drop the path completer after a + selection, so later numeric-menu and y/n prompts no longer inherit file-path + tab completion. + ## [2.14.0] - 2026-07-24 ### Added diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index f1e704c..89ec117 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -183,6 +183,7 @@ def quick_crack(ctx: Any) -> None: print("Please enter a valid wordlist or wordlist directory.") except ValueError: print("Please enter a valid number.") + readline.set_completer(None) selected_rules = _select_rules(ctx) if selected_rules is None: @@ -488,6 +489,7 @@ def _prompt_wordlist_paths(ctx, max_count: int) -> list[str]: count += 1 else: print(f"Not found: {resolved}") + readline.set_completer(None) return collected @@ -620,8 +622,10 @@ def _omen_pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"): if sel.lower() == "q": return None if sel.lower() == "p": - path = input("\n\tPath to wordlist: ").strip() - return path if path else None + path = ctx.select_file_with_autocomplete( + "\tPath to wordlist (tab to autocomplete)" + ) + return path.strip() if path else None try: idx = int(sel) if 1 <= idx <= len(wordlist_files): @@ -717,8 +721,10 @@ def _markov_pick_training_source(ctx: Any): if sel == "0" and has_cracked: return out_path if sel.lower() == "p": - path = input("\n\tPath to training file: ").strip() - return path if path else None + path = ctx.select_file_with_autocomplete( + "\tPath to training file (tab to autocomplete)" + ) + return path.strip() if path else None try: idx = int(sel) if 1 <= idx <= len(wordlist_files): @@ -795,7 +801,10 @@ def combipow_crack(ctx: Any) -> None: _notify.prompt_notify_for_attack("Combipow") wordlist = None while wordlist is None: - path = input("\nEnter path to wordlist (max 63 lines recommended): ").strip() + path = ctx.select_file_with_autocomplete( + "Enter path to wordlist (max 63 lines recommended, tab to autocomplete)" + ) + path = path.strip() if path else "" if not path: continue if not os.path.isfile(path): @@ -889,9 +898,11 @@ def generate_rules_crack(ctx: Any) -> None: wordlist_choice = raw_choice else: print("[!] Wordlist not found. Please enter a valid path.") + readline.set_completer(None) return except ValueError: print("Please enter a valid number.") + readline.set_completer(None) ctx.hcatGenerateRules(ctx.hcatHashType, ctx.hcatHashFile, rule_count, wordlist_choice) @@ -967,6 +978,7 @@ def permute_crack(ctx: Any) -> None: print("[!] A directory was provided. Please enter a single wordlist file.") continue wordlist_path = raw + readline.set_completer(None) ctx.hcatPermute(ctx.hcatHashType, ctx.hcatHashFile, wordlist_path) @@ -1015,7 +1027,10 @@ def _rule_select_file(ctx: Any, prompt: str = "Rule file: ") -> str: return None _configure_readline(rule_completer) - return input(prompt).strip() + try: + return input(prompt).strip() + finally: + readline.set_completer(None) def rule_cleanup_handler(ctx: Any) -> None: @@ -1026,7 +1041,10 @@ def rule_cleanup_handler(ctx: Any) -> None: if not infile or not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = input("Output file path: ").strip() + outfile = ctx.select_file_with_autocomplete( + "Output file path (tab to autocomplete)" + ) + outfile = outfile.strip() if outfile else "" if not outfile: print("[!] Output path required.") return @@ -1044,7 +1062,10 @@ def rule_optimize_handler(ctx: Any) -> None: if not infile or not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = input("Output file path: ").strip() + outfile = ctx.select_file_with_autocomplete( + "Output file path (tab to autocomplete)" + ) + outfile = outfile.strip() if outfile else "" if not outfile: print("[!] Output path required.") return @@ -1064,7 +1085,10 @@ def rule_cleanup_and_optimize_handler(ctx: Any) -> None: if not infile or not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = input("Output file path: ").strip() + outfile = ctx.select_file_with_autocomplete( + "Output file path (tab to autocomplete)" + ) + outfile = outfile.strip() if outfile else "" if not outfile: print("[!] Output path required.") return diff --git a/hate_crack/main.py b/hate_crack/main.py index 6018b51..5380ba9 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -1230,7 +1230,12 @@ def select_file_with_autocomplete( full_prompt += f" (default: {default})" full_prompt += ": " - result = input(full_prompt).strip() + try: + result = input(full_prompt).strip() + finally: + # Drop the path completer so later plain prompts (numeric menus, y/n) + # don't inherit stale file-path tab completion. + readline.set_completer(None) if not result and base_dir: result = base_dir diff --git a/tests/test_combipow_attack.py b/tests/test_combipow_attack.py index 7d6f0eb..8f30e16 100644 --- a/tests/test_combipow_attack.py +++ b/tests/test_combipow_attack.py @@ -66,7 +66,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("correct\nhorse\nbattery\n") - with patch("builtins.input", side_effect=[str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() call_args = ctx.hcatCombipow.call_args @@ -75,12 +76,23 @@ class TestCombipowCrack: ) assert use_space is True + def test_wordlist_path_uses_autocomplete_not_input(self, tmp_path): + attacks = _load_attacks() + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("correct\nhorse\n") + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): + attacks.combipow_crack(ctx) + ctx.select_file_with_autocomplete.assert_called_once() + def test_calls_hcatCombipow_without_space_sep(self, tmp_path): attacks = _load_attacks() ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("correct\nhorse\nbattery\n") - with patch("builtins.input", side_effect=[str(wl), "n"]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=["n"]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() call_args = ctx.hcatCombipow.call_args @@ -94,7 +106,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("\n".join(f"word{i}" for i in range(64)) + "\n") - with patch("builtins.input", return_value=str(wl)): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_not_called() @@ -103,7 +116,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("\n".join(f"word{i}" for i in range(63)) + "\n") - with patch("builtins.input", side_effect=[str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() @@ -112,7 +126,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("test\n") - with patch("builtins.input", side_effect=["/nonexistent.txt", str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = ["/nonexistent.txt", str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() @@ -121,7 +136,8 @@ class TestCombipowCrack: ctx = _make_ctx(hash_type="3200", hash_file="/tmp/bcrypt.txt") wl = tmp_path / "words.txt" wl.write_text("word1\nword2\n") - with patch("builtins.input", side_effect=[str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() args = ctx.hcatCombipow.call_args[0] @@ -134,7 +150,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("\n".join(f"word{i}" for i in range(31)) + "\n") - with patch("builtins.input", side_effect=[str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) captured = capsys.readouterr() assert "large" in captured.out.lower() or "warning" in captured.out.lower() diff --git a/tests/test_custom_path_autocomplete.py b/tests/test_custom_path_autocomplete.py new file mode 100644 index 0000000..26e5c92 --- /dev/null +++ b/tests/test_custom_path_autocomplete.py @@ -0,0 +1,80 @@ +"""Regression tests: custom-path prompts must offer tab autocomplete. + +Previously the "p. Enter a custom path" branches (OMEN, Markov) and the +combipow wordlist prompt used a bare ``input()`` with no readline completer, +so TAB did nothing. They now route through ``select_file_with_autocomplete``. +Also covers the canonical selector dropping its completer afterward so later +plain prompts don't inherit stale file-path completion. +""" + +import os +import readline +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +os.environ["HATE_CRACK_SKIP_INIT"] = "1" + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _load_attacks(): + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + import hate_crack.attacks as attacks # noqa: PLC0415 + + return attacks + + +def _make_ctx(): + ctx = MagicMock() + ctx.hcatHashType = "1000" + ctx.hcatHashFile = "/tmp/hashes.txt" + ctx.hcatWordlists = "/tmp/wordlists" + ctx.list_wordlist_files.return_value = [] + return ctx + + +class TestOmenCustomPath: + def test_custom_path_uses_autocomplete(self): + attacks = _load_attacks() + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = "/data/custom.txt" + with patch("builtins.input", side_effect=["p"]): + result = attacks._omen_pick_training_wordlist(ctx) + ctx.select_file_with_autocomplete.assert_called_once() + assert result == "/data/custom.txt" + + def test_custom_path_blank_returns_none(self): + attacks = _load_attacks() + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = None + with patch("builtins.input", side_effect=["p"]): + result = attacks._omen_pick_training_wordlist(ctx) + assert result is None + + +class TestMarkovCustomPath: + def test_custom_path_uses_autocomplete(self): + attacks = _load_attacks() + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = "/data/train.txt" + # out file absent -> "0" option not offered + with patch("builtins.input", side_effect=["p"]): + result = attacks._markov_pick_training_source(ctx) + ctx.select_file_with_autocomplete.assert_called_once() + assert result == "/data/train.txt" + + +class TestSelectorResetsCompleter: + def test_completer_reset_after_selection(self): + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + import hate_crack.main as m # noqa: PLC0415 + + sentinel = object() + readline.set_completer(lambda t, s: None) + with patch("builtins.input", return_value="/some/path"): + m.select_file_with_autocomplete("Pick a file") + assert readline.get_completer() is None + del sentinel diff --git a/tests/test_omen_attack.py b/tests/test_omen_attack.py index cc61f9e..7239df1 100644 --- a/tests/test_omen_attack.py +++ b/tests/test_omen_attack.py @@ -355,12 +355,14 @@ class TestOmenAttackHandler: def test_custom_path_for_training(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=False) self._setup_rules_dir(tmp_path) + ctx.select_file_with_autocomplete.return_value = "/custom/wordlist.txt" with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["p", "/custom/wordlist.txt", "", "0"] + "builtins.input", side_effect=["p", "", "0"] ): from hate_crack.attacks import omen_attack omen_attack(ctx) + ctx.select_file_with_autocomplete.assert_called_once() ctx.hcatOmenTrain.assert_called_once_with("/custom/wordlist.txt") def test_rules_passed_to_hcatOmen(self, tmp_path): diff --git a/tests/test_rule_tools.py b/tests/test_rule_tools.py index 051f01b..19b5b90 100644 --- a/tests/test_rule_tools.py +++ b/tests/test_rule_tools.py @@ -26,7 +26,8 @@ class TestRuleCleanupHandler: infile = tmp_path / "test.rule" infile.write_text("l\nu\n") outfile = tmp_path / "clean.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_handler(ctx) ctx.rules_cleanup.assert_called_once_with(str(infile), str(outfile)) @@ -40,7 +41,8 @@ class TestRuleCleanupHandler: ctx = _make_ctx() infile = tmp_path / "test.rule" infile.write_text("l\n") - with patch("builtins.input", side_effect=[str(infile), ""]): + ctx.select_file_with_autocomplete.return_value = "" + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_handler(ctx) ctx.rules_cleanup.assert_not_called() @@ -49,7 +51,8 @@ class TestRuleCleanupHandler: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "clean.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_handler(ctx) assert "[+] Done." in capsys.readouterr().out @@ -59,7 +62,8 @@ class TestRuleCleanupHandler: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "clean.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_handler(ctx) assert "[!] Cleanup failed." in capsys.readouterr().out @@ -70,7 +74,8 @@ class TestRuleOptimizeHandler: infile = tmp_path / "test.rule" infile.write_text("l\nu\n") outfile = tmp_path / "optimized.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_optimize_handler(ctx) ctx.rules_optimize.assert_called_once_with(str(infile), str(outfile)) @@ -84,7 +89,8 @@ class TestRuleOptimizeHandler: ctx = _make_ctx() infile = tmp_path / "test.rule" infile.write_text("l\n") - with patch("builtins.input", side_effect=[str(infile), ""]): + ctx.select_file_with_autocomplete.return_value = "" + with patch("builtins.input", side_effect=[str(infile)]): rule_optimize_handler(ctx) ctx.rules_optimize.assert_not_called() @@ -93,7 +99,8 @@ class TestRuleOptimizeHandler: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "optimized.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_optimize_handler(ctx) assert "[+] Done." in capsys.readouterr().out @@ -103,7 +110,8 @@ class TestRuleOptimizeHandler: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "optimized.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_optimize_handler(ctx) assert "[!] Optimize failed." in capsys.readouterr().out @@ -114,7 +122,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\nu\n") outfile = tmp_path / "final.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) ctx.rules_cleanup.assert_called_once() ctx.rules_optimize.assert_called_once() @@ -125,7 +134,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "out.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) ctx.rules_optimize.assert_not_called() @@ -139,7 +149,8 @@ class TestRuleCleanupAndOptimize: ctx = _make_ctx() infile = tmp_path / "test.rule" infile.write_text("l\n") - with patch("builtins.input", side_effect=[str(infile), ""]): + ctx.select_file_with_autocomplete.return_value = "" + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) ctx.rules_cleanup.assert_not_called() @@ -155,7 +166,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "final.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) assert captured_tmp, "rules_cleanup should have been called" assert not os.path.exists(captured_tmp[0]), "temp file should be cleaned up" @@ -172,7 +184,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "out.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) if captured_tmp: assert not os.path.exists(captured_tmp[0]), "temp file should be cleaned up" @@ -182,7 +195,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "final.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) assert "[+] Done." in capsys.readouterr().out