diff --git a/README.md b/README.md index 0a69f8b..a11d8c2 100644 --- a/README.md +++ b/README.md @@ -619,6 +619,8 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi (17) Ad-hoc Mask Attack (18) Markov Brute Force Attack + (80) Wordlist Tools + (90) Download rules from Hashmob.net (91) Analyze Hashcat Rules (92) Download wordlists from Hashmob.net @@ -789,6 +791,21 @@ Generates password candidates using Markov chain statistical models. Similar to * Markov table persists with hash file (filename.out.hcstat2) for fast subsequent runs * Faster than OMEN for general-purpose brute forcing +#### Wordlist Tools (option 80) +A submenu of wordlist preprocessing utilities using hashcat-utils binaries. All tools read from and write to files on disk. + +| Key | Tool | Description | +|-----|------|-------------| +| 1 | Filter by Length | Keep only words between a min and max length (`len.bin`) | +| 2 | Require Char Classes | Keep words that include all char classes in mask (`req-include.bin`). Mask: 1=lower, 2=upper, 4=digit, 8=symbol (additive) | +| 3 | Exclude Char Classes | Remove words containing any char class in mask (`req-exclude.bin`). Same mask encoding | +| 4 | Extract Substring | Cut bytes from each word at a given offset and optional length (`cutb.bin`) | +| 5 | Split by Length | Create per-length files in an output directory (`splitlen.bin`) | +| 6 | Subtract Wordlist | Remove lines from a wordlist that appear in one or more remove files. Mode 1 uses `rli2.bin` (single file); mode 2 uses `rli.bin` (multiple files) | +| 7 | Shard Wordlist | Extract every mod-th line at a given offset to create equal-sized shards (`gate.bin`) | + +All binaries are in `hate_crack/hashcat-utils/bin/`. + #### Download Rules from Hashmob.net Downloads the latest rule files from Hashmob.net's rule repository. These rules are curated and optimized for password cracking and can be used with the Quick Crack and Loopback Attack modes. diff --git a/hate_crack.py b/hate_crack.py index de0124b..abf7365 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -88,6 +88,7 @@ def get_main_menu_options(): "16": _attacks.omen_attack, "17": _attacks.adhoc_mask_crack, "18": _attacks.markov_brute_force, + "80": _attacks.wordlist_tools_submenu, "90": download_hashmob_rules, "91": weakpass_wordlist_menu, "92": download_hashmob_wordlists, diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index d06e250..a94c8e4 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -5,6 +5,7 @@ from typing import Any from hate_crack.api import download_hashmob_rules from hate_crack.formatting import print_multicolumn_list +from hate_crack.menu import interactive_menu def _configure_readline(completer): @@ -621,8 +622,6 @@ def markov_brute_force(ctx: Any) -> None: def combinator_submenu(ctx: Any) -> None: - from hate_crack.menu import interactive_menu - items = [ ("1", "Combinator Attack"), ("2", "YOLO Combinator Attack"), @@ -642,3 +641,195 @@ def combinator_submenu(ctx: Any) -> None: middle_combinator(ctx) elif choice == "4": thorough_combinator(ctx) + + +def wordlist_filter_length(ctx: Any) -> None: + """Prompt for paths and lengths, then filter wordlist by word length.""" + infile = input("\n[*] Enter path to input wordlist: ").strip() + if not os.path.isfile(infile): + print(f"[!] File not found: {infile}") + return + outfile = input("[*] Enter path to output wordlist: ").strip() + if not outfile: + print("[!] Output path cannot be empty.") + return + min_len = int(input("[*] Minimum length: ").strip() or "0") + max_len = int(input("[*] Maximum length: ").strip() or "0") + if ctx.wordlist_filter_len(infile, outfile, min_len, max_len): + print(f"\n[*] Filtered wordlist written to: {outfile}") + else: + print("[!] Filter failed.") + + +def wordlist_filter_charclass_include(ctx: Any) -> None: + """Prompt for paths and mask, then keep only words matching required char classes.""" + infile = input("\n[*] Enter path to input wordlist: ").strip() + if not os.path.isfile(infile): + print(f"[!] File not found: {infile}") + return + outfile = input("[*] Enter path to output wordlist: ").strip() + if not outfile: + print("[!] Output path cannot be empty.") + return + print("[*] Char class mask: 1=lowercase, 2=uppercase, 4=digit, 8=symbol (additive, e.g. 3=lower+upper)") + mask = int(input("[*] Enter mask value: ").strip() or "0") + if ctx.wordlist_filter_req_include(infile, outfile, mask): + print(f"\n[*] Filtered wordlist written to: {outfile}") + else: + print("[!] Filter failed.") + + +def wordlist_filter_charclass_exclude(ctx: Any) -> None: + """Prompt for paths and mask, then remove words containing excluded char classes.""" + infile = input("\n[*] Enter path to input wordlist: ").strip() + if not os.path.isfile(infile): + print(f"[!] File not found: {infile}") + return + outfile = input("[*] Enter path to output wordlist: ").strip() + if not outfile: + print("[!] Output path cannot be empty.") + return + print("[*] Char class mask: 1=lowercase, 2=uppercase, 4=digit, 8=symbol (additive)") + mask = int(input("[*] Enter mask value: ").strip() or "0") + if ctx.wordlist_filter_req_exclude(infile, outfile, mask): + print(f"\n[*] Filtered wordlist written to: {outfile}") + else: + print("[!] Filter failed.") + + +def wordlist_cut_substring(ctx: Any) -> None: + """Prompt for paths, offset, and optional length, then extract substring from each word.""" + infile = input("\n[*] Enter path to input wordlist: ").strip() + if not os.path.isfile(infile): + print(f"[!] File not found: {infile}") + return + outfile = input("[*] Enter path to output wordlist: ").strip() + if not outfile: + print("[!] Output path cannot be empty.") + return + offset = int(input("[*] Byte offset to start from: ").strip() or "0") + raw_length = input("[*] Length (leave blank for rest of line): ").strip() + length = int(raw_length) if raw_length else None + if ctx.wordlist_cutb(infile, outfile, offset, length): + print(f"\n[*] Output written to: {outfile}") + else: + print("[!] Cut failed.") + + +def wordlist_split_by_length(ctx: Any) -> None: + """Prompt for input wordlist and output directory, then split by word length.""" + infile = input("\n[*] Enter path to input wordlist: ").strip() + if not os.path.isfile(infile): + print(f"[!] File not found: {infile}") + return + outdir = input("[*] Enter output directory path: ").strip() + if not outdir: + print("[!] Output directory cannot be empty.") + return + os.makedirs(outdir, exist_ok=True) + if ctx.wordlist_splitlen(infile, outdir): + print(f"\n[*] Split wordlists written to: {outdir}") + else: + print("[!] Split failed.") + + +def wordlist_subtract_words(ctx: Any) -> None: + """Prompt for mode then remove matching lines from a wordlist.""" + print("\n[*] Subtract mode:") + print(" 1. Single remove file (rli2 - faster for one file)") + print(" 2. Multiple remove files (rli)") + mode = input("[*] Choose mode (1/2): ").strip() + + if mode == "1": + infile = input("[*] Enter path to input wordlist: ").strip() + if not os.path.isfile(infile): + print(f"[!] File not found: {infile}") + return + remove_file = input("[*] Enter path to wordlist to subtract: ").strip() + if not os.path.isfile(remove_file): + print(f"[!] File not found: {remove_file}") + return + outfile = input("[*] Enter path to output wordlist: ").strip() + if not outfile: + print("[!] Output path cannot be empty.") + return + if ctx.wordlist_subtract_single(infile, remove_file, outfile): + print(f"\n[*] Result written to: {outfile}") + else: + print("[!] Subtraction failed.") + elif mode == "2": + infile = input("[*] Enter path to input wordlist: ").strip() + if not os.path.isfile(infile): + print(f"[!] File not found: {infile}") + return + outfile = input("[*] Enter path to output wordlist: ").strip() + if not outfile: + print("[!] Output path cannot be empty.") + return + raw = input("[*] Enter remove file paths (comma-separated): ").strip() + remove_files = [r.strip() for r in raw.split(",") if r.strip()] + if not remove_files: + print("[!] No remove files provided.") + return + if ctx.wordlist_subtract(infile, outfile, *remove_files): + print(f"\n[*] Deduplicated wordlist written to: {outfile}") + else: + print("[!] Subtraction failed.") + else: + print("[!] Invalid mode.") + + +def wordlist_shard(ctx: Any) -> None: + """Prompt for input/output paths, modulus, and offset, then shard the wordlist.""" + infile = input("\n[*] Enter path to input wordlist: ").strip() + if not os.path.isfile(infile): + print(f"[!] File not found: {infile}") + return + outfile = input("[*] Enter path to output wordlist: ").strip() + if not outfile: + print("[!] Output path cannot be empty.") + return + mod = int(input("[*] Modulus (shard count, e.g. 4 for 4 shards): ").strip() or "0") + if mod < 2: + print("[!] Modulus must be at least 2.") + return + offset = int(input("[*] Offset (shard index, 0-based, e.g. 0 for first shard): ").strip() or "0") + if offset >= mod: + print(f"[!] Offset must be less than modulus ({mod}).") + return + if ctx.wordlist_gate(infile, outfile, mod, offset): + print(f"\n[*] Shard written to: {outfile}") + else: + print("[!] Shard failed.") + + +def wordlist_tools_submenu(ctx: Any) -> None: + """Display the Wordlist Tools submenu and dispatch to the selected handler.""" + items = [ + ("1", "Filter by Length"), + ("2", "Require Character Classes"), + ("3", "Exclude Character Classes"), + ("4", "Extract Substring"), + ("5", "Split by Length"), + ("6", "Subtract Wordlist"), + ("7", "Shard Wordlist"), + ("99", "Back to Main Menu"), + ] + while True: + choice = interactive_menu(items, title="\nWordlist Tools:") + if choice is None or choice == "99": + break + elif choice == "1": + wordlist_filter_length(ctx) + elif choice == "2": + wordlist_filter_charclass_include(ctx) + elif choice == "3": + wordlist_filter_charclass_exclude(ctx) + elif choice == "4": + wordlist_cut_substring(ctx) + elif choice == "5": + wordlist_split_by_length(ctx) + elif choice == "6": + wordlist_subtract_words(ctx) + elif choice == "7": + wordlist_shard(ctx) diff --git a/hate_crack/main.py b/hate_crack/main.py index e36bc14..db5eca1 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -3329,6 +3329,80 @@ def omen_attack(): return _attacks.omen_attack(_attack_ctx()) +def wordlist_filter_len(infile: str, outfile: str, min_len: int, max_len: int) -> bool: + """Filter wordlist keeping only words between min_len and max_len (inclusive).""" + len_bin = os.path.join(hate_path, "hashcat-utils/bin/len.bin") + with open(infile, "rb") as fin, open(outfile, "wb") as fout: + result = subprocess.run( + [len_bin, str(min_len), str(max_len)], stdin=fin, stdout=fout + ) + return result.returncode == 0 + + +def wordlist_filter_req_include(infile: str, outfile: str, mask: int) -> bool: + """Filter wordlist keeping only words that include all char classes in mask.""" + req_bin = os.path.join(hate_path, "hashcat-utils/bin/req-include.bin") + with open(infile, "rb") as fin, open(outfile, "wb") as fout: + result = subprocess.run([req_bin, str(mask)], stdin=fin, stdout=fout) + return result.returncode == 0 + + +def wordlist_filter_req_exclude(infile: str, outfile: str, mask: int) -> bool: + """Filter wordlist removing words that contain any char class in mask.""" + req_bin = os.path.join(hate_path, "hashcat-utils/bin/req-exclude.bin") + with open(infile, "rb") as fin, open(outfile, "wb") as fout: + result = subprocess.run([req_bin, str(mask)], stdin=fin, stdout=fout) + return result.returncode == 0 + + +def wordlist_cutb( + infile: str, outfile: str, offset: int, length: int | None +) -> bool: + """Extract a substring from each word starting at offset, optionally limited to length bytes.""" + cutb_bin = os.path.join(hate_path, "hashcat-utils/bin/cutb.bin") + cmd = [cutb_bin, str(offset)] + if length is not None: + cmd.append(str(length)) + with open(infile, "rb") as fin, open(outfile, "wb") as fout: + result = subprocess.run(cmd, stdin=fin, stdout=fout) + return result.returncode == 0 + + +def wordlist_splitlen(infile: str, outdir: str) -> bool: + """Split wordlist into per-length files in outdir.""" + splitlen_bin = os.path.join(hate_path, "hashcat-utils/bin/splitlen.bin") + with open(infile, "rb") as fin: + result = subprocess.run([splitlen_bin, outdir], stdin=fin) + return result.returncode == 0 + + +def wordlist_subtract(infile: str, outfile: str, *remove_files: str) -> bool: + """Remove lines from infile that appear in any of remove_files, write to outfile.""" + rli_bin = os.path.join(hate_path, "hashcat-utils/bin/rli.bin") + result = subprocess.run([rli_bin, infile, outfile, *remove_files]) + return result.returncode == 0 + + +def wordlist_subtract_single(infile: str, remove_file: str, outfile: str) -> bool: + """Subtract remove_file from infile, writing result to stdout captured in outfile.""" + rli2_bin = os.path.join(hate_path, "hashcat-utils/bin/rli2.bin") + with open(outfile, "wb") as fout: + result = subprocess.run([rli2_bin, infile, remove_file], stdout=fout) + return result.returncode == 0 + + +def wordlist_gate(infile: str, outfile: str, mod: int, offset: int) -> bool: + """Shard wordlist: keep every mod-th line starting at offset.""" + gate_bin = os.path.join(hate_path, "hashcat-utils/bin/gate.bin") + with open(infile, "rb") as fin, open(outfile, "wb") as fout: + result = subprocess.run([gate_bin, str(mod), str(offset)], stdin=fin, stdout=fout) + return result.returncode == 0 + + +def wordlist_tools_submenu(): + return _attacks.wordlist_tools_submenu(_attack_ctx()) + + # convert hex words for recycling def convert_hex(working_file): processed_words = [] @@ -3557,6 +3631,7 @@ def get_main_menu_items(): ("16", "OMEN Attack"), ("17", "Ad-hoc Mask Attack"), ("18", "Markov Brute Force Attack"), + ("80", "Wordlist Tools"), ("90", "Download rules from Hashmob.net"), ("91", "Analyze Hashcat Rules"), ("92", "Download wordlists from Hashmob.net"), @@ -3594,6 +3669,7 @@ def get_main_menu_options(): "16": omen_attack, "17": adhoc_mask_crack, "18": markov_brute_force, + "80": wordlist_tools_submenu, "90": lambda: download_hashmob_rules(rules_dir=rulesDirectory), "91": analyze_rules, "92": download_hashmob_wordlists, diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index fd3bfa6..1241106 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -26,6 +26,7 @@ MENU_OPTION_TEST_CASES = [ ("16", CLI_MODULE._attacks, "omen_attack", "omen"), ("17", CLI_MODULE._attacks, "adhoc_mask_crack", "adhoc-mask"), ("18", CLI_MODULE._attacks, "markov_brute_force", "markov-brute"), + ("80", CLI_MODULE._attacks, "wordlist_tools_submenu", "wordlist-tools"), ("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"), ("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"), ("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"), diff --git a/tests/test_wordlist_tools.py b/tests/test_wordlist_tools.py new file mode 100644 index 0000000..b4c538f --- /dev/null +++ b/tests/test_wordlist_tools.py @@ -0,0 +1,296 @@ +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from hate_crack.attacks import ( + wordlist_cut_substring, + wordlist_filter_charclass_exclude, + wordlist_filter_charclass_include, + wordlist_filter_length, + wordlist_shard, + wordlist_split_by_length, + wordlist_subtract_words, + wordlist_tools_submenu, +) + + +def _make_ctx(): + ctx = MagicMock() + ctx.wordlist_filter_len.return_value = True + ctx.wordlist_filter_req_include.return_value = True + ctx.wordlist_filter_req_exclude.return_value = True + ctx.wordlist_cutb.return_value = True + ctx.wordlist_splitlen.return_value = True + ctx.wordlist_subtract.return_value = True + ctx.wordlist_subtract_single.return_value = True + ctx.wordlist_gate.return_value = True + return ctx + + +class TestWordlistFilterLength: + def test_calls_wordlist_filter_len(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "in.txt" + infile.write_text("test\n") + outfile = tmp_path / "out.txt" + with patch("builtins.input", side_effect=[str(infile), str(outfile), "4", "8"]): + wordlist_filter_length(ctx) + ctx.wordlist_filter_len.assert_called_once_with(str(infile), str(outfile), 4, 8) + + def test_rejects_nonexistent_infile(self, tmp_path): + ctx = _make_ctx() + with patch("builtins.input", return_value="/nonexistent/file.txt"): + wordlist_filter_length(ctx) + ctx.wordlist_filter_len.assert_not_called() + + def test_rejects_empty_outfile(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "in.txt" + infile.write_text("test\n") + with patch("builtins.input", side_effect=[str(infile), ""]): + wordlist_filter_length(ctx) + ctx.wordlist_filter_len.assert_not_called() + + def test_prints_success_message(self, tmp_path, capsys): + ctx = _make_ctx() + infile = tmp_path / "in.txt" + infile.write_text("test\n") + outfile = tmp_path / "out.txt" + with patch("builtins.input", side_effect=[str(infile), str(outfile), "4", "8"]): + wordlist_filter_length(ctx) + out = capsys.readouterr().out + assert "success" in out.lower() or "wrote" in out.lower() or str(outfile) in out + + def test_prints_failure_message(self, tmp_path, capsys): + ctx = _make_ctx() + ctx.wordlist_filter_len.return_value = False + infile = tmp_path / "in.txt" + infile.write_text("test\n") + outfile = tmp_path / "out.txt" + with patch("builtins.input", side_effect=[str(infile), str(outfile), "4", "8"]): + wordlist_filter_length(ctx) + out = capsys.readouterr().out + assert "fail" in out.lower() or "error" in out.lower() or "!" in out + + +class TestWordlistFilterCharclassInclude: + def test_calls_wordlist_filter_req_include(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "in.txt" + infile.write_text("test\n") + outfile = tmp_path / "out.txt" + with patch("builtins.input", side_effect=[str(infile), str(outfile), "7"]): + wordlist_filter_charclass_include(ctx) + ctx.wordlist_filter_req_include.assert_called_once_with(str(infile), str(outfile), 7) + + def test_rejects_nonexistent_infile(self, tmp_path): + ctx = _make_ctx() + with patch("builtins.input", return_value="/nonexistent/file.txt"): + wordlist_filter_charclass_include(ctx) + ctx.wordlist_filter_req_include.assert_not_called() + + +class TestWordlistFilterCharclassExclude: + def test_calls_wordlist_filter_req_exclude(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "in.txt" + infile.write_text("test\n") + outfile = tmp_path / "out.txt" + with patch("builtins.input", side_effect=[str(infile), str(outfile), "8"]): + wordlist_filter_charclass_exclude(ctx) + ctx.wordlist_filter_req_exclude.assert_called_once_with(str(infile), str(outfile), 8) + + def test_rejects_nonexistent_infile(self, tmp_path): + ctx = _make_ctx() + with patch("builtins.input", return_value="/nonexistent/file.txt"): + wordlist_filter_charclass_exclude(ctx) + ctx.wordlist_filter_req_exclude.assert_not_called() + + +class TestWordlistCutSubstring: + def test_calls_wordlist_cutb_with_length(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "in.txt" + infile.write_text("test\n") + outfile = tmp_path / "out.txt" + with patch("builtins.input", side_effect=[str(infile), str(outfile), "2", "4"]): + wordlist_cut_substring(ctx) + ctx.wordlist_cutb.assert_called_once_with(str(infile), str(outfile), 2, 4) + + def test_calls_wordlist_cutb_without_length(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "in.txt" + infile.write_text("test\n") + outfile = tmp_path / "out.txt" + with patch("builtins.input", side_effect=[str(infile), str(outfile), "2", ""]): + wordlist_cut_substring(ctx) + ctx.wordlist_cutb.assert_called_once_with(str(infile), str(outfile), 2, None) + + def test_rejects_nonexistent_infile(self, tmp_path): + ctx = _make_ctx() + with patch("builtins.input", return_value="/nonexistent/file.txt"): + wordlist_cut_substring(ctx) + ctx.wordlist_cutb.assert_not_called() + + +class TestWordlistSplitByLength: + def test_calls_wordlist_splitlen(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "in.txt" + infile.write_text("test\n") + outdir = tmp_path / "split" + with patch("builtins.input", side_effect=[str(infile), str(outdir)]): + wordlist_split_by_length(ctx) + ctx.wordlist_splitlen.assert_called_once_with(str(infile), str(outdir)) + + def test_creates_outdir_if_missing(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "in.txt" + infile.write_text("test\n") + outdir = tmp_path / "split" / "nested" + with patch("builtins.input", side_effect=[str(infile), str(outdir)]): + wordlist_split_by_length(ctx) + assert outdir.exists() + + def test_rejects_nonexistent_infile(self, tmp_path): + ctx = _make_ctx() + with patch("builtins.input", return_value="/nonexistent/file.txt"): + wordlist_split_by_length(ctx) + ctx.wordlist_splitlen.assert_not_called() + + +class TestWordlistSubtractWords: + def test_single_remove_calls_wordlist_subtract_single(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "in.txt" + infile.write_text("word1\n") + removefile = tmp_path / "remove.txt" + removefile.write_text("word1\n") + outfile = tmp_path / "out.txt" + # "1" = single remove + with patch("builtins.input", side_effect=["1", str(infile), str(removefile), str(outfile)]): + wordlist_subtract_words(ctx) + ctx.wordlist_subtract_single.assert_called_once_with(str(infile), str(removefile), str(outfile)) + + def test_multi_remove_calls_wordlist_subtract(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "in.txt" + infile.write_text("word1\nword2\n") + removefile1 = tmp_path / "remove1.txt" + removefile1.write_text("word1\n") + removefile2 = tmp_path / "remove2.txt" + removefile2.write_text("word2\n") + outfile = tmp_path / "out.txt" + # "2" = multiple removes, then two remove files separated by comma or newline + with patch( + "builtins.input", + side_effect=["2", str(infile), str(outfile), f"{removefile1},{removefile2}"], + ): + wordlist_subtract_words(ctx) + ctx.wordlist_subtract.assert_called_once_with( + str(infile), str(outfile), str(removefile1), str(removefile2) + ) + + def test_single_remove_rejects_nonexistent_infile(self, tmp_path): + ctx = _make_ctx() + with patch("builtins.input", side_effect=["1", "/nonexistent.txt"]): + wordlist_subtract_words(ctx) + ctx.wordlist_subtract_single.assert_not_called() + + +class TestWordlistShard: + def test_calls_wordlist_gate_with_correct_args(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "in.txt" + infile.write_text("word1\nword2\nword3\n") + outfile = tmp_path / "shard.txt" + with patch("builtins.input", side_effect=[str(infile), str(outfile), "3", "0"]): + wordlist_shard(ctx) + ctx.wordlist_gate.assert_called_once_with(str(infile), str(outfile), 3, 0) + + def test_rejects_offset_gte_mod(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "in.txt" + infile.write_text("word1\n") + outfile = tmp_path / "shard.txt" + with patch("builtins.input", side_effect=[str(infile), str(outfile), "3", "3"]): + wordlist_shard(ctx) + ctx.wordlist_gate.assert_not_called() + + def test_rejects_nonexistent_infile(self, tmp_path): + ctx = _make_ctx() + with patch("builtins.input", return_value="/nonexistent/file.txt"): + wordlist_shard(ctx) + ctx.wordlist_gate.assert_not_called() + + def test_rejects_mod_less_than_2(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "in.txt" + infile.write_text("word1\n") + outfile = tmp_path / "shard.txt" + with patch("builtins.input", side_effect=[str(infile), str(outfile), "1", "0"]): + wordlist_shard(ctx) + ctx.wordlist_gate.assert_not_called() + + +class TestWordlistToolsSubmenu: + def test_submenu_dispatches_to_filter_length(self): + ctx = _make_ctx() + with patch("hate_crack.attacks.wordlist_filter_length") as mock_fn, \ + patch("hate_crack.attacks.interactive_menu", side_effect=["1", "99"]): + wordlist_tools_submenu(ctx) + mock_fn.assert_called_once_with(ctx) + + def test_submenu_dispatches_to_filter_charclass_include(self): + ctx = _make_ctx() + with patch("hate_crack.attacks.wordlist_filter_charclass_include") as mock_fn, \ + patch("hate_crack.attacks.interactive_menu", side_effect=["2", "99"]): + wordlist_tools_submenu(ctx) + mock_fn.assert_called_once_with(ctx) + + def test_submenu_dispatches_to_filter_charclass_exclude(self): + ctx = _make_ctx() + with patch("hate_crack.attacks.wordlist_filter_charclass_exclude") as mock_fn, \ + patch("hate_crack.attacks.interactive_menu", side_effect=["3", "99"]): + wordlist_tools_submenu(ctx) + mock_fn.assert_called_once_with(ctx) + + def test_submenu_dispatches_to_cut_substring(self): + ctx = _make_ctx() + with patch("hate_crack.attacks.wordlist_cut_substring") as mock_fn, \ + patch("hate_crack.attacks.interactive_menu", side_effect=["4", "99"]): + wordlist_tools_submenu(ctx) + mock_fn.assert_called_once_with(ctx) + + def test_submenu_dispatches_to_split_by_length(self): + ctx = _make_ctx() + with patch("hate_crack.attacks.wordlist_split_by_length") as mock_fn, \ + patch("hate_crack.attacks.interactive_menu", side_effect=["5", "99"]): + wordlist_tools_submenu(ctx) + mock_fn.assert_called_once_with(ctx) + + def test_submenu_dispatches_to_subtract_words(self): + ctx = _make_ctx() + with patch("hate_crack.attacks.wordlist_subtract_words") as mock_fn, \ + patch("hate_crack.attacks.interactive_menu", side_effect=["6", "99"]): + wordlist_tools_submenu(ctx) + mock_fn.assert_called_once_with(ctx) + + def test_submenu_dispatches_to_shard(self): + ctx = _make_ctx() + with patch("hate_crack.attacks.wordlist_shard") as mock_fn, \ + patch("hate_crack.attacks.interactive_menu", side_effect=["7", "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"): + wordlist_tools_submenu(ctx) + + def test_submenu_exits_on_none(self): + ctx = _make_ctx() + with patch("hate_crack.attacks.interactive_menu", return_value=None): + wordlist_tools_submenu(ctx) diff --git a/tests/test_wordlist_wrappers.py b/tests/test_wordlist_wrappers.py new file mode 100644 index 0000000..5b3ed9b --- /dev/null +++ b/tests/test_wordlist_wrappers.py @@ -0,0 +1,177 @@ +import os +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch, call +import importlib.util + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +CLI_SPEC = importlib.util.spec_from_file_location( + "hate_crack_cli", PROJECT_ROOT / "hate_crack.py" +) +CLI_MODULE = importlib.util.module_from_spec(CLI_SPEC) +CLI_SPEC.loader.exec_module(CLI_MODULE) + + +def _get_main(): + import hate_crack.main as m + return m + + +class TestWordlistFilterLenWrapper: + def test_calls_len_bin_with_correct_args(self, tmp_path): + m = _get_main() + infile = tmp_path / "in.txt" + infile.write_text("test\n") + outfile = tmp_path / "out.txt" + with patch("hate_crack.main.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + result = m.wordlist_filter_len(str(infile), str(outfile), 4, 8) + assert result is True + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert cmd[1] == "4" + assert cmd[2] == "8" + assert "len.bin" in cmd[0] + + def test_returns_false_on_nonzero_returncode(self, tmp_path): + m = _get_main() + infile = tmp_path / "in.txt" + infile.write_text("test\n") + outfile = tmp_path / "out.txt" + with patch("hate_crack.main.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=1) + result = m.wordlist_filter_len(str(infile), str(outfile), 4, 8) + assert result is False + + +class TestWordlistFilterReqIncludeWrapper: + def test_calls_req_include_bin(self, tmp_path): + m = _get_main() + infile = tmp_path / "in.txt" + infile.write_text("test\n") + outfile = tmp_path / "out.txt" + with patch("hate_crack.main.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + result = m.wordlist_filter_req_include(str(infile), str(outfile), 7) + assert result is True + cmd = mock_run.call_args[0][0] + assert "req-include.bin" in cmd[0] + assert cmd[1] == "7" + + +class TestWordlistFilterReqExcludeWrapper: + def test_calls_req_exclude_bin(self, tmp_path): + m = _get_main() + infile = tmp_path / "in.txt" + infile.write_text("test\n") + outfile = tmp_path / "out.txt" + with patch("hate_crack.main.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + result = m.wordlist_filter_req_exclude(str(infile), str(outfile), 8) + assert result is True + cmd = mock_run.call_args[0][0] + assert "req-exclude.bin" in cmd[0] + assert cmd[1] == "8" + + +class TestWordlistCutbWrapper: + def test_calls_cutb_bin_with_offset_and_length(self, tmp_path): + m = _get_main() + infile = tmp_path / "in.txt" + infile.write_text("test\n") + outfile = tmp_path / "out.txt" + with patch("hate_crack.main.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + result = m.wordlist_cutb(str(infile), str(outfile), 2, 4) + assert result is True + cmd = mock_run.call_args[0][0] + assert "cutb.bin" in cmd[0] + assert cmd[1] == "2" + assert cmd[2] == "4" + + def test_calls_cutb_bin_without_length(self, tmp_path): + m = _get_main() + infile = tmp_path / "in.txt" + infile.write_text("test\n") + outfile = tmp_path / "out.txt" + with patch("hate_crack.main.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + result = m.wordlist_cutb(str(infile), str(outfile), 2, None) + assert result is True + cmd = mock_run.call_args[0][0] + assert "cutb.bin" in cmd[0] + assert cmd[1] == "2" + assert len(cmd) == 2 # only binary + offset, no length arg + + +class TestWordlistSplitlenWrapper: + def test_calls_splitlen_bin(self, tmp_path): + m = _get_main() + infile = tmp_path / "in.txt" + infile.write_text("test\n") + outdir = tmp_path / "split" + with patch("hate_crack.main.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + result = m.wordlist_splitlen(str(infile), str(outdir)) + assert result is True + cmd = mock_run.call_args[0][0] + assert "splitlen.bin" in cmd[0] + assert cmd[1] == str(outdir) + + +class TestWordlistSubtractWrapper: + def test_calls_rli_bin_with_multiple_files(self, tmp_path): + m = _get_main() + infile = tmp_path / "in.txt" + infile.write_text("word1\n") + outfile = tmp_path / "out.txt" + remove1 = tmp_path / "r1.txt" + remove1.write_text("word1\n") + remove2 = tmp_path / "r2.txt" + remove2.write_text("word2\n") + with patch("hate_crack.main.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + result = m.wordlist_subtract(str(infile), str(outfile), str(remove1), str(remove2)) + assert result is True + cmd = mock_run.call_args[0][0] + assert "rli.bin" in cmd[0] + assert cmd[1] == str(infile) + assert cmd[2] == str(outfile) + assert str(remove1) in cmd + assert str(remove2) in cmd + + +class TestWordlistSubtractSingleWrapper: + def test_calls_rli2_bin(self, tmp_path): + m = _get_main() + infile = tmp_path / "in.txt" + infile.write_text("word1\n") + removefile = tmp_path / "remove.txt" + removefile.write_text("word1\n") + outfile = tmp_path / "out.txt" + with patch("hate_crack.main.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + result = m.wordlist_subtract_single(str(infile), str(removefile), str(outfile)) + assert result is True + cmd = mock_run.call_args[0][0] + assert "rli2.bin" in cmd[0] + assert cmd[1] == str(infile) + assert cmd[2] == str(removefile) + + +class TestWordlistGateWrapper: + def test_calls_gate_bin(self, tmp_path): + m = _get_main() + infile = tmp_path / "in.txt" + infile.write_text("word1\nword2\nword3\n") + outfile = tmp_path / "shard.txt" + with patch("hate_crack.main.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + result = m.wordlist_gate(str(infile), str(outfile), 3, 0) + assert result is True + cmd = mock_run.call_args[0][0] + assert "gate.bin" in cmd[0] + assert cmd[1] == "3" + assert cmd[2] == "0"