From 0d3282654ba639e02166470f5660e6d9b24ddf5a Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 21:20:53 -0400 Subject: [PATCH] fix(wordlist-optimize): remove allow_multiple crash, redundant import, add worker tests - Remove `allow_multiple=True` from wordlist_optimize handler to avoid AttributeError when the return value is a list rather than a str - Remove redundant `import tempfile` inside wordlist_optimize worker body (tempfile is already imported at module level on line 29) - Add TestWordlistOptimizeWorker with 6 tests covering: fast-path (empty outdir), merge-path (subtract+append), new-length copy, splitlen failure, subtract failure, and missing-input skip Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/attacks.py | 1 - hate_crack/main.py | 1 - tests/test_wordlist_tools.py | 171 ++++++++++++++++++++++++++++++++++- 3 files changed, 170 insertions(+), 3 deletions(-) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 2394bec..3f3d415 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -1196,7 +1196,6 @@ 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()] diff --git a/hate_crack/main.py b/hate_crack/main.py index 8419961..cb2c3af 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -4013,7 +4013,6 @@ def wordlist_gate(infile: str, outfile: str, mod: int, offset: int) -> bool: 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): diff --git a/tests/test_wordlist_tools.py b/tests/test_wordlist_tools.py index e6a4114..30e12fe 100644 --- a/tests/test_wordlist_tools.py +++ b/tests/test_wordlist_tools.py @@ -1,6 +1,7 @@ import os +import shutil from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch import pytest @@ -386,3 +387,171 @@ class TestWordlistOptimize: wordlist_optimize(ctx) out = capsys.readouterr().out assert "Optimization failed" in out + + +class TestWordlistOptimizeWorker: + """Tests for the wordlist_optimize worker function in hate_crack.main. + + All binary-calling helpers (wordlist_splitlen, wordlist_subtract_single) + are mocked so no real binaries are required. + """ + + def _get_worker(self): + import importlib + import sys + # Import the module fresh; SKIP_INIT is already active via conftest. + mod = sys.modules.get("hate_crack.main") + if mod is None: + mod = importlib.import_module("hate_crack.main") + return mod.wordlist_optimize + + # ------------------------------------------------------------------ + # (a) fast-path: empty outdir → wordlist_splitlen called directly + # ------------------------------------------------------------------ + def test_fast_path_empty_outdir(self, tmp_path): + worker = self._get_worker() + wl = tmp_path / "words.txt" + wl.write_text("word\n") + outdir = tmp_path / "out" + outdir.mkdir() + + with patch("hate_crack.main.wordlist_splitlen", return_value=True) as mock_split, \ + patch("hate_crack.main.wordlist_subtract_single") as mock_sub: + result = worker([str(wl)], str(outdir)) + + assert result is True + mock_split.assert_called_once_with(str(wl), str(outdir)) + mock_sub.assert_not_called() + + # ------------------------------------------------------------------ + # (b) merge-path: existing per-length file → subtract_single + append + # ------------------------------------------------------------------ + def test_merge_path_existing_length_file(self, tmp_path): + worker = self._get_worker() + 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 = tmp_path / "out" + outdir.mkdir() + + # After the first wordlist, outdir contains "len5.txt" + len_file = outdir / "len5.txt" + len_file.write_text("word1\n") + + # The second call goes through the merge path for the tmp subdir. + # wordlist_splitlen for wl_b produces "len5.txt" in a temp dir. + # wordlist_subtract_single produces a non-empty output → append. + new_words = b"word2\n" + + def fake_splitlen(infile: str, outdir_arg: str) -> bool: + # Write a len5.txt into wherever we are called to write to + (Path(outdir_arg) / "len5.txt").write_bytes(b"word2\n") + return True + + def fake_subtract(src: str, dst: str, out: str) -> bool: + # Simulate: the diff is "word2\n" + with open(out, "wb") as f: + f.write(new_words) + return True + + with patch("hate_crack.main.wordlist_splitlen", side_effect=fake_splitlen), \ + patch("hate_crack.main.wordlist_subtract_single", side_effect=fake_subtract): + # outdir is already non-empty (len_file exists), so wl_b goes to merge path + result = worker([str(wl_b)], str(outdir)) + + assert result is True + # len5.txt should now contain the appended new words + contents = len_file.read_bytes() + assert b"word2\n" in contents + + # ------------------------------------------------------------------ + # (c) new length file in subsequent wordlist is just copied + # ------------------------------------------------------------------ + def test_new_length_file_is_copied(self, tmp_path): + worker = self._get_worker() + wl_b = tmp_path / "b.txt" + wl_b.write_text("verylongword\n") + outdir = tmp_path / "out" + outdir.mkdir() + + # Seed outdir with one length file (len5) so it is non-empty + (outdir / "len5.txt").write_text("hello\n") + + # The second wordlist produces only "len12.txt" (no clash) + def fake_splitlen(infile: str, outdir_arg: str) -> bool: + (Path(outdir_arg) / "len12.txt").write_bytes(b"verylongword\n") + return True + + with patch("hate_crack.main.wordlist_splitlen", side_effect=fake_splitlen), \ + patch("hate_crack.main.wordlist_subtract_single") as mock_sub: + result = worker([str(wl_b)], str(outdir)) + + assert result is True + assert (outdir / "len12.txt").exists() + assert (outdir / "len12.txt").read_bytes() == b"verylongword\n" + mock_sub.assert_not_called() + + # ------------------------------------------------------------------ + # (d) wordlist_splitlen failure returns False + # ------------------------------------------------------------------ + def test_splitlen_failure_returns_false(self, tmp_path): + worker = self._get_worker() + wl = tmp_path / "words.txt" + wl.write_text("word\n") + outdir = tmp_path / "out" + outdir.mkdir() + + with patch("hate_crack.main.wordlist_splitlen", return_value=False): + result = worker([str(wl)], str(outdir)) + + assert result is False + + # ------------------------------------------------------------------ + # (e) wordlist_subtract_single failure returns False + # ------------------------------------------------------------------ + def test_subtract_failure_returns_false(self, tmp_path): + worker = self._get_worker() + wl_b = tmp_path / "b.txt" + wl_b.write_text("word2\n") + outdir = tmp_path / "out" + outdir.mkdir() + + # Seed outdir so it's non-empty and has a clashing length file + (outdir / "len5.txt").write_text("word1\n") + + def fake_splitlen(infile: str, outdir_arg: str) -> bool: + (Path(outdir_arg) / "len5.txt").write_bytes(b"word2\n") + return True + + with patch("hate_crack.main.wordlist_splitlen", side_effect=fake_splitlen), \ + patch("hate_crack.main.wordlist_subtract_single", return_value=False): + result = worker([str(wl_b)], str(outdir)) + + assert result is False + + # ------------------------------------------------------------------ + # (f) missing input file is skipped and processing continues + # ------------------------------------------------------------------ + def test_missing_input_skipped_processing_continues(self, tmp_path, capsys): + worker = self._get_worker() + good_wl = tmp_path / "good.txt" + good_wl.write_text("word\n") + missing = str(tmp_path / "nonexistent.txt") + outdir = tmp_path / "out" + outdir.mkdir() + + call_count = {"n": 0} + + def fake_splitlen(infile: str, outdir_arg: str) -> bool: + call_count["n"] += 1 + return True + + with patch("hate_crack.main.wordlist_splitlen", side_effect=fake_splitlen): + result = worker([missing, str(good_wl)], str(outdir)) + + assert result is True + # Only the good wordlist should have been processed + assert call_count["n"] == 1 + out = capsys.readouterr().out + assert "Skipping" in out