From cf12c3264e5e92c533d8a2a0d164c47a87b612d8 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 21:13:10 -0400 Subject: [PATCH] feat(wordlist-tools): add Optimize Wordlists submenu option (8) Adds wordlist_optimize worker to main.py that consolidates multiple wordlists into per-length deduplicated files using splitlen.bin and rli2.bin. Wires handler in attacks.py and registers it as option 8 in the Wordlist Tools submenu. Adds 16 tests covering happy path, empty input, missing files, empty outdir, failure path, and submenu dispatch. Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/attacks.py | 30 ++++++++++++++ hate_crack/main.py | 37 +++++++++++++++++ tests/test_wordlist_tools.py | 80 ++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index ab3a1c2..2394bec 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -1192,6 +1192,33 @@ def wordlist_shard(ctx: Any) -> None: print("[!] Shard failed.") +def wordlist_optimize(ctx: Any) -> None: + """Prompt for input wordlists and output directory, then optimize.""" + raw = ctx.select_file_with_autocomplete( + "\n[*] Enter input wordlist paths", + allow_multiple=True, + base_dir=ctx.hcatWordlists, + ).strip() + inputs = [p.strip() for p in raw.split(",") if p.strip()] + if not inputs: + print("[!] No input wordlists provided.") + return + missing = [p for p in inputs if not os.path.isfile(p)] + if missing: + print("[!] Files not found:") + for p in missing: + print(f" {p}") + return + outdir = ctx.select_file_with_autocomplete("[*] Enter output directory path").strip() + if not outdir: + print("[!] Output directory cannot be empty.") + return + if ctx.wordlist_optimize(inputs, outdir): + print(f"\n[*] Optimized wordlists written to: {outdir}") + else: + print("[!] Optimization failed.") + + def wordlist_tools_submenu(ctx: Any) -> None: """Display the Wordlist Tools submenu and dispatch to the selected handler.""" items = [ @@ -1202,6 +1229,7 @@ def wordlist_tools_submenu(ctx: Any) -> None: ("5", "Split by Length"), ("6", "Subtract Wordlist"), ("7", "Shard Wordlist"), + ("8", "Optimize Wordlists"), ("99", "Back to Main Menu"), ] while True: @@ -1222,3 +1250,5 @@ def wordlist_tools_submenu(ctx: Any) -> None: wordlist_subtract_words(ctx) elif choice == "7": wordlist_shard(ctx) + elif choice == "8": + wordlist_optimize(ctx) diff --git a/hate_crack/main.py b/hate_crack/main.py index 9f36343..8419961 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -4011,6 +4011,43 @@ def wordlist_gate(infile: str, outfile: str, mod: int, offset: int) -> bool: return result.returncode == 0 +def wordlist_optimize(input_wordlists: list[str], outdir: str) -> bool: + """Consolidate wordlists into per-length deduplicated files in outdir.""" + import tempfile + os.makedirs(outdir, exist_ok=True) + for wl in input_wordlists: + if not os.path.isfile(wl): + print(f"[!] Skipping missing wordlist: {wl}") + continue + if not os.listdir(outdir): + if not wordlist_splitlen(wl, outdir): + return False + continue + with tempfile.TemporaryDirectory(prefix="hc_optimize_") as tmp: + if not wordlist_splitlen(wl, tmp): + return False + for fname in os.listdir(tmp): + src = os.path.join(tmp, fname) + dst = os.path.join(outdir, fname) + if not os.path.isfile(dst): + shutil.copyfile(src, dst) + continue + with tempfile.NamedTemporaryFile( + delete=False, prefix="hc_optimize_", suffix=".out" + ) as out_fh: + out_path = out_fh.name + try: + if not wordlist_subtract_single(src, dst, out_path): + return False + if os.path.getsize(out_path) > 0: + with open(dst, "ab") as df, open(out_path, "rb") as sf: + df.write(sf.read()) + finally: + if os.path.isfile(out_path): + os.remove(out_path) + return True + + def wordlist_tools_submenu(): return _attacks.wordlist_tools_submenu(_attack_ctx()) diff --git a/tests/test_wordlist_tools.py b/tests/test_wordlist_tools.py index 36cd5b8..e6a4114 100644 --- a/tests/test_wordlist_tools.py +++ b/tests/test_wordlist_tools.py @@ -9,6 +9,7 @@ from hate_crack.attacks import ( wordlist_filter_charclass_exclude, wordlist_filter_charclass_include, wordlist_filter_length, + wordlist_optimize, wordlist_shard, wordlist_split_by_length, wordlist_subtract_words, @@ -26,6 +27,7 @@ def _make_ctx(): ctx.wordlist_subtract.return_value = True ctx.wordlist_subtract_single.return_value = True ctx.wordlist_gate.return_value = True + ctx.wordlist_optimize.return_value = True return ctx @@ -297,6 +299,13 @@ class TestWordlistToolsSubmenu: wordlist_tools_submenu(ctx) mock_fn.assert_called_once_with(ctx) + def test_submenu_dispatches_to_optimize(self): + ctx = _make_ctx() + with patch("hate_crack.attacks.wordlist_optimize") as mock_fn, \ + patch("hate_crack.attacks.interactive_menu", side_effect=["8", "99"]): + wordlist_tools_submenu(ctx) + mock_fn.assert_called_once_with(ctx) + def test_submenu_exits_on_99(self): ctx = _make_ctx() with patch("hate_crack.attacks.interactive_menu", return_value="99"): @@ -306,3 +315,74 @@ class TestWordlistToolsSubmenu: ctx = _make_ctx() with patch("hate_crack.attacks.interactive_menu", return_value=None): wordlist_tools_submenu(ctx) + + +class TestWordlistOptimize: + def test_happy_path(self, tmp_path, capsys): + ctx = _make_ctx() + wl_a = tmp_path / "a.txt" + wl_a.write_text("word1\n") + wl_b = tmp_path / "b.txt" + wl_b.write_text("word2\n") + outdir = str(tmp_path / "out") + ctx.select_file_with_autocomplete.side_effect = [ + f"{wl_a},{wl_b}", + outdir, + ] + with patch("hate_crack.attacks.os.path.isfile", return_value=True): + wordlist_optimize(ctx) + ctx.wordlist_optimize.assert_called_once_with( + [str(wl_a), str(wl_b)], outdir + ) + out = capsys.readouterr().out + assert outdir in out + + def test_empty_input_rejection(self, capsys): + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = "," + wordlist_optimize(ctx) + out = capsys.readouterr().out + assert "No input wordlists provided" in out + ctx.wordlist_optimize.assert_not_called() + + def test_blank_input_rejection(self, capsys): + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = "" + wordlist_optimize(ctx) + out = capsys.readouterr().out + assert "No input wordlists provided" in out + ctx.wordlist_optimize.assert_not_called() + + def test_missing_file_rejection(self, tmp_path, capsys): + ctx = _make_ctx() + existing = tmp_path / "a.txt" + existing.write_text("word\n") + ctx.select_file_with_autocomplete.return_value = f"{existing},/nonexistent/missing.txt" + with patch("hate_crack.attacks.os.path.isfile", side_effect=lambda p: p == str(existing)): + wordlist_optimize(ctx) + out = capsys.readouterr().out + assert "Files not found" in out + ctx.wordlist_optimize.assert_not_called() + + def test_empty_outdir_rejection(self, tmp_path, capsys): + ctx = _make_ctx() + wl = tmp_path / "a.txt" + wl.write_text("word\n") + ctx.select_file_with_autocomplete.side_effect = [str(wl), ""] + with patch("hate_crack.attacks.os.path.isfile", return_value=True): + wordlist_optimize(ctx) + out = capsys.readouterr().out + assert "Output directory cannot be empty" in out + ctx.wordlist_optimize.assert_not_called() + + def test_failure_path(self, tmp_path, capsys): + ctx = _make_ctx() + ctx.wordlist_optimize.return_value = False + wl = tmp_path / "a.txt" + wl.write_text("word\n") + outdir = str(tmp_path / "out") + ctx.select_file_with_autocomplete.side_effect = [str(wl), outdir] + with patch("hate_crack.attacks.os.path.isfile", return_value=True): + wordlist_optimize(ctx) + out = capsys.readouterr().out + assert "Optimization failed" in out