feat: add wordlist tools submenu (len, req-include, req-exclude, cutb, rli, rli2, splitlen, gate) (#90, #91, #92, #94)

Add Wordlist Tools submenu (key 80) with 7 preprocessing utilities backed by hashcat-utils binaries:

- Filter by length (len.bin) - #90
- Require/exclude character classes (req-include.bin, req-exclude.bin) - #90
- Extract substring (cutb.bin) - #90
- Remove matching lines (rli.bin, rli2.bin) - #91
- Split by length (splitlen.bin) - #92
- Shard wordlist (gate.bin) - #94

Three-layer pattern:
- main.py: low-level wrappers (wordlist_filter_len, wordlist_filter_req_include,
  wordlist_filter_req_exclude, wordlist_cutb, wordlist_splitlen, wordlist_subtract,
  wordlist_subtract_single, wordlist_gate) return bool for testability
- attacks.py: UI handlers with input validation and the wordlist_tools_submenu dispatcher
- hate_crack.py + main.py: menu item "80" wired in both get_main_menu_items and
  get_main_menu_options

Move interactive_menu import to attacks.py module level (was local import in
combinator_submenu) to support patching in tests.
This commit is contained in:
Justin Bollinger
2026-03-19 12:15:43 -04:00
parent 346ef32f20
commit c51ae332c6
7 changed files with 761 additions and 2 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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"),

View File

@@ -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)

View File

@@ -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"