Merge branch 'fix/custom-path-tab-completion': tab completion on custom file-path prompts (v2.14.1)

This commit is contained in:
Justin Bollinger
2026-07-25 16:17:28 -04:00
7 changed files with 188 additions and 32 deletions
+14
View File
@@ -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
+33 -9
View File
@@ -181,6 +181,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:
@@ -492,6 +493,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
@@ -626,8 +628,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):
@@ -727,8 +731,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):
@@ -805,7 +811,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):
@@ -901,9 +910,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
@@ -981,6 +992,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)
@@ -1028,7 +1040,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:
@@ -1039,7 +1054,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
@@ -1057,7 +1075,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
@@ -1077,7 +1098,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
+6 -1
View File
@@ -1238,7 +1238,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
+24 -7
View File
@@ -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()
+80
View File
@@ -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
+3 -1
View File
@@ -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):
+28 -14
View File
@@ -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