Files
hate_crack/tests/test_wordlist_tools.py
Justin Bollinger c51ae332c6 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.
2026-03-19 12:15:43 -04:00

297 lines
12 KiB
Python

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)