feat: unify combinator attacks into single 2-8 wordlist handler

- Merge combinator, combinator3, and combinatorX into one unified
  combinator_crack function that routes by wordlist count:
  2 (no sep) -> hcatCombination, 3 (no sep) -> hcatCombinator3,
  4+ or any separator -> hcatCombinatorX
- Replace comma-separated wordlist input with one-at-a-time
  tab-autocomplete prompts (blank line to finish)
- Add _prompt_wordlist_paths helper using existing readline infrastructure
- Add hcatCombinator3Wordlist and hcatCombinatorXWordlist config vars
  with rockyou.txt defaults
- Print full hashcat command to stdout in --debug mode by calling
  _debug_cmd at the end of _append_potfile_arg (covers all 27 invocations)
- Collapse combinator submenu from 6 options to 4; keep combinator3_crack,
  combinatorX_crack, and combinator_3plus_crack as delegation shims
- Update tests to cover unified routing and new prompt interface
This commit is contained in:
Justin Bollinger
2026-03-19 14:18:25 -04:00
parent e2f25bfc70
commit 20f9110fc1
5 changed files with 181 additions and 254 deletions

View File

@@ -8,6 +8,8 @@
"rules_directory": "./hashcat/rules",
"hcatDictionaryWordlist": ["rockyou.txt"],
"hcatCombinationWordlist": ["rockyou.txt","rockyou.txt"],
"hcatCombinator3Wordlist": ["rockyou.txt","rockyou.txt","rockyou.txt"],
"hcatCombinatorXWordlist": ["rockyou.txt","rockyou.txt"],
"hcatHybridlist": ["rockyou.txt"],
"hcatMiddleCombinatorMasks": ["2","4"," ","-","_","+",",",".","&"],
"hcatMiddleBaseList": "rockyou.txt",

View File

@@ -262,8 +262,7 @@ def combinator_crack(ctx: Any) -> None:
print("\n" + "=" * 60)
print("COMBINATOR ATTACK")
print("=" * 60)
print("This attack combines two wordlists to generate candidates.")
print("Example: wordlist1='password' + wordlist2='123' = 'password123'")
print("Combines 2-8 wordlists. 2 uses hashcat native mode; 3+ use external binaries.")
print("=" * 60)
use_default = (
@@ -271,73 +270,30 @@ def combinator_crack(ctx: Any) -> None:
)
if use_default != "n":
print("\nUsing default wordlist(s) from config:")
if isinstance(ctx.hcatCombinationWordlist, list):
for wl in ctx.hcatCombinationWordlist:
print(f" - {wl}")
wordlists = ctx.hcatCombinationWordlist
else:
print(f" - {ctx.hcatCombinationWordlist}")
wordlists = [ctx.hcatCombinationWordlist]
else:
print("\nSelect wordlists for combinator attack.")
print("You need to provide exactly 2 wordlists.")
print("You can enter:")
print(" - Two file paths separated by commas")
print(" - Press TAB to autocomplete file paths")
selection = ctx.select_file_with_autocomplete(
"Enter 2 wordlist files (comma-separated)",
allow_multiple=True,
base_dir=ctx.hcatWordlists,
)
if not selection:
print("No wordlists selected. Aborting combinator attack.")
base = ctx.hcatCombinationWordlist
wordlists = base if isinstance(base, list) else [base]
wordlists = [ctx._resolve_wordlist_path(wl, ctx.hcatWordlists) for wl in wordlists]
if len(wordlists) < 2:
print("\n[!] Config does not have at least 2 wordlists.")
print("Set hcatCombinationWordlist to a list of 2+ paths in config.json.")
print("Aborting combinator attack.")
return
if isinstance(selection, str):
wordlists = [selection]
else:
wordlists = selection
separator = ""
else:
print("\nEnter 2-8 wordlists. Enter a blank line when done.")
wordlists = _prompt_wordlist_paths(ctx, max_count=8)
if len(wordlists) < 2:
print("\n[!] Combinator attack requires at least 2 wordlists.")
print("Aborting combinator attack.")
return
separator = input("\nEnter separator between words (leave blank for none): ").strip()
valid_wordlists = []
for wl in wordlists[:2]: # Only use first 2
resolved = ctx._resolve_wordlist_path(wl, ctx.hcatWordlists)
if os.path.isfile(resolved):
valid_wordlists.append(resolved)
print(f"✓ Found: {resolved}")
else:
print(f"✗ Not found: {resolved}")
if len(valid_wordlists) < 2:
print("\nCould not find 2 valid wordlists. Aborting combinator attack.")
return
wordlists = valid_wordlists
wordlists = [
ctx._resolve_wordlist_path(wl, ctx.hcatWordlists) for wl in wordlists[:2]
]
if len(wordlists) < 2:
print("\n[!] Combinator attack requires 2 wordlists but only 1 is configured.")
print("Set hcatCombinationWordlist to a list of 2 paths in config.json.")
print("Aborting combinator attack.")
return
print("\nStarting combinator attack with 2 wordlists:")
print(f" Wordlist 1: {wordlists[0]}")
print(f" Wordlist 2: {wordlists[1]}")
print(f"Hash type: {ctx.hcatHashType}")
print(f"Hash file: {ctx.hcatHashFile}")
ctx.hcatCombination(ctx.hcatHashType, ctx.hcatHashFile, wordlists)
if len(wordlists) == 2 and not separator:
ctx.hcatCombination(ctx.hcatHashType, ctx.hcatHashFile, wordlists)
elif len(wordlists) == 3 and not separator:
ctx.hcatCombinator3(ctx.hcatHashType, ctx.hcatHashFile, wordlists)
else:
ctx.hcatCombinatorX(ctx.hcatHashType, ctx.hcatHashFile, wordlists, separator or None)
def hybrid_crack(ctx: Any) -> None:
@@ -428,108 +384,64 @@ def middle_combinator(ctx: Any) -> None:
ctx.hcatMiddleCombinator(ctx.hcatHashType, ctx.hcatHashFile)
def combinator3_crack(ctx: Any) -> None:
print("\n" + "=" * 60)
print("COMBINATOR3 ATTACK")
print("=" * 60)
print("This attack combines three wordlists to generate candidates.")
print("=" * 60)
def _prompt_wordlist_paths(ctx, max_count: int) -> list[str]:
"""Prompt for wordlist paths one at a time with tab-autocomplete.
use_default = (
input("\nUse default combinator wordlists from config? (Y/n): ").strip().lower()
)
Stops when a blank line is entered or max_count paths have been collected.
Returns a list of resolved, valid file paths.
"""
if use_default != "n":
base = ctx.hcatCombinationWordlist
wordlists = base if isinstance(base, list) else [base]
if len(wordlists) < 3:
print("\n[!] Config does not have 3 wordlists for combinator3.")
print("Set hcatCombinationWordlist to a list of 3 paths in config.json.")
print("Aborting combinator3 attack.")
return
else:
def path_completer(text, state):
base = ctx.hcatWordlists
if not text:
pattern = os.path.join(base, "*")
matches = glob.glob(pattern)
else:
expanded = os.path.expanduser(text)
if expanded.startswith(("/", "./", "../", "~")):
matches = glob.glob(expanded + "*")
else:
pattern = os.path.join(base, expanded + "*")
matches = glob.glob(pattern)
matches = [m + "/" if os.path.isdir(m) else m for m in matches]
try:
return matches[state]
except IndexError:
return None
_configure_readline(path_completer)
collected: list[str] = []
count = 1
while len(collected) < max_count:
raw = input(
"\nEnter 3 wordlist file paths (comma-separated): "
f"\nWordlist #{count} (tab to autocomplete, blank to finish): "
).strip()
if not raw:
print("No wordlists provided. Aborting combinator3 attack.")
return
break
resolved = ctx._resolve_wordlist_path(raw, ctx.hcatWordlists)
if os.path.isfile(resolved):
collected.append(resolved)
print(f"Added: {resolved}")
count += 1
else:
print(f"Not found: {resolved}")
return collected
entries = [p.strip() for p in raw.split(",") if p.strip()]
if len(entries) < 3:
print("\n[!] Combinator3 attack requires exactly 3 wordlists.")
print("Aborting combinator3 attack.")
return
valid = []
for p in entries[:3]:
resolved = ctx._resolve_wordlist_path(p, ctx.hcatWordlists)
if os.path.isfile(resolved):
valid.append(resolved)
print(f"Found: {resolved}")
else:
print(f"Not found: {resolved}")
if len(valid) < 3:
print("\nCould not find 3 valid wordlists. Aborting combinator3 attack.")
return
wordlists = valid
ctx.hcatCombinator3(ctx.hcatHashType, ctx.hcatHashFile, wordlists)
def combinator3_crack(ctx: Any) -> None:
"""3-way combinator attack (delegates to unified combinator_crack)."""
combinator_crack(ctx)
def combinatorX_crack(ctx: Any) -> None:
print("\n" + "=" * 60)
print("COMBINATORX ATTACK")
print("=" * 60)
print("This attack combines 2-8 wordlists with an optional separator.")
print("=" * 60)
"""N-way combinator attack (delegates to unified combinator_crack)."""
combinator_crack(ctx)
use_default = (
input("\nUse default combinator wordlists from config? (Y/n): ").strip().lower()
)
if use_default != "n":
base = ctx.hcatCombinationWordlist
wordlists = base if isinstance(base, list) else [base]
if len(wordlists) < 2:
print("\n[!] Config does not have at least 2 wordlists for combinatorX.")
print("Set hcatCombinationWordlist to a list of 2+ paths in config.json.")
print("Aborting combinatorX attack.")
return
separator = ""
else:
raw = input(
"\nEnter 2-8 wordlist file paths (comma-separated): "
).strip()
if not raw:
print("No wordlists provided. Aborting combinatorX attack.")
return
entries = [p.strip() for p in raw.split(",") if p.strip()]
if len(entries) < 2:
print("\n[!] CombinatorX attack requires at least 2 wordlists.")
print("Aborting combinatorX attack.")
return
valid = []
for p in entries[:8]:
resolved = ctx._resolve_wordlist_path(p, ctx.hcatWordlists)
if os.path.isfile(resolved):
valid.append(resolved)
print(f"Found: {resolved}")
else:
print(f"Not found: {resolved}")
if len(valid) < 2:
print("\nCould not find 2 valid wordlists. Aborting combinatorX attack.")
return
wordlists = valid
separator = input("\nEnter separator between words (leave blank for none): ").strip()
ctx.hcatCombinatorX(ctx.hcatHashType, ctx.hcatHashFile, wordlists, separator or None)
def combinator_3plus_crack(ctx: Any) -> None:
"""3+ wordlist combinator (delegates to unified combinator_crack)."""
combinator_crack(ctx)
def bandrel_method(ctx: Any) -> None:
@@ -727,12 +639,10 @@ def markov_brute_force(ctx: Any) -> None:
def combinator_submenu(ctx: Any) -> None:
items = [
("1", "Combinator Attack"),
("1", "Combinator Attack (2-8 wordlists)"),
("2", "YOLO Combinator Attack"),
("3", "Middle Combinator Attack"),
("4", "Thorough Combinator Attack"),
("5", "Combinator3 Attack (3-way)"),
("6", "CombinatorX Attack (N-way, 2-8 wordlists)"),
("99", "Back to Main Menu"),
]
while True:
@@ -747,7 +657,3 @@ def combinator_submenu(ctx: Any) -> None:
middle_combinator(ctx)
elif choice == "4":
thorough_combinator(ctx)
elif choice == "5":
combinator3_crack(ctx)
elif choice == "6":
combinatorX_crack(ctx)

View File

@@ -356,19 +356,19 @@ else:
def _append_potfile_arg(cmd, *, use_potfile_path=True, potfile_path=None):
if not use_potfile_path:
return
pot = potfile_path or hcatPotfilePath
if pot:
try:
pot_dir = os.path.dirname(pot)
if pot_dir:
os.makedirs(pot_dir, exist_ok=True)
if not os.path.exists(pot):
open(pot, "a").close()
except OSError:
pass
cmd.append(f"--potfile-path={pot}")
if use_potfile_path:
pot = potfile_path or hcatPotfilePath
if pot:
try:
pot_dir = os.path.dirname(pot)
if pot_dir:
os.makedirs(pot_dir, exist_ok=True)
if not os.path.exists(pot):
open(pot, "a").close()
except OSError:
pass
cmd.append(f"--potfile-path={pot}")
_debug_cmd(cmd)
rulesDirectory = config_parser["rules_directory"]
@@ -401,6 +401,8 @@ pipalPath = config_parser["pipalPath"]
hcatDictionaryWordlist = config_parser["hcatDictionaryWordlist"]
hcatHybridlist = config_parser["hcatHybridlist"]
hcatCombinationWordlist = config_parser["hcatCombinationWordlist"]
hcatCombinator3Wordlist = config_parser.get("hcatCombinator3Wordlist", ["rockyou.txt", "rockyou.txt", "rockyou.txt"])
hcatCombinatorXWordlist = config_parser.get("hcatCombinatorXWordlist", ["rockyou.txt", "rockyou.txt"])
hcatMiddleCombinatorMasks = config_parser["hcatMiddleCombinatorMasks"]
hcatMiddleBaseList = config_parser["hcatMiddleBaseList"]
hcatThoroughCombinatorMasks = config_parser["hcatThoroughCombinatorMasks"]
@@ -558,6 +560,8 @@ hcatDictionaryWordlist = _normalize_wordlist_setting(
hcatCombinationWordlist = _normalize_wordlist_setting(
hcatCombinationWordlist, wordlists_dir
)
hcatCombinator3Wordlist = _normalize_wordlist_setting(hcatCombinator3Wordlist, wordlists_dir)
hcatCombinatorXWordlist = _normalize_wordlist_setting(hcatCombinatorXWordlist, wordlists_dir)
hcatHybridlist = _normalize_wordlist_setting(hcatHybridlist, wordlists_dir)
hcatMiddleBaseList = _normalize_wordlist_setting(hcatMiddleBaseList, wordlists_dir)
hcatThoroughBaseList = _normalize_wordlist_setting(hcatThoroughBaseList, wordlists_dir)
@@ -3391,6 +3395,10 @@ def combinatorX_crack():
return _attacks.combinatorX_crack(_attack_ctx())
def combinator_3plus_crack():
return _attacks.combinator_3plus_crack(_attack_ctx())
def combinator_submenu():
return _attacks.combinator_submenu(_attack_ctx())

View File

@@ -216,7 +216,7 @@ class TestCombinatorCrack:
ctx._resolve_wordlist_path.assert_any_call("/wl/a.txt", ctx.hcatWordlists)
ctx._resolve_wordlist_path.assert_any_call("/wl/b.txt", ctx.hcatWordlists)
def test_uses_only_first_two_wordlists(self) -> None:
def test_three_wordlists_in_config_routes_to_combinator3(self) -> None:
ctx = _make_ctx()
ctx.hcatCombinationWordlist = ["/wl/a.txt", "/wl/b.txt", "/wl/c.txt"]
ctx._resolve_wordlist_path.side_effect = lambda wl, _: wl
@@ -224,9 +224,10 @@ class TestCombinatorCrack:
with patch("builtins.input", return_value=""):
combinator_crack(ctx)
call_wordlists = ctx.hcatCombination.call_args[0][2]
assert len(call_wordlists) == 2
assert "/wl/c.txt" not in call_wordlists
ctx.hcatCombinator3.assert_called_once()
ctx.hcatCombination.assert_not_called()
call_wordlists = ctx.hcatCombinator3.call_args[0][2]
assert len(call_wordlists) == 3
class TestHybridCrack:

View File

@@ -1,9 +1,6 @@
import os
from unittest.mock import MagicMock, patch
import pytest
from hate_crack.attacks import combinator3_crack, combinator_submenu, combinatorX_crack
from hate_crack.attacks import combinator_crack, combinator_submenu
def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"):
@@ -13,122 +10,134 @@ def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"):
return ctx
class TestCombinator3Crack:
def test_calls_hcatCombinator3_with_three_wordlists(self, tmp_path):
class TestCombinatorCrackUnified:
def test_two_wordlists_calls_hcatCombination(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
for name in ["a.txt", "b.txt"]:
(tmp_path / name).write_text("word\n")
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
inputs = ["n", f"{tmp_path}/a.txt", f"{tmp_path}/b.txt", "", ""]
with patch("builtins.input", side_effect=inputs):
combinator_crack(ctx)
ctx.hcatCombination.assert_called_once()
ctx.hcatCombinator3.assert_not_called()
ctx.hcatCombinatorX.assert_not_called()
def test_three_wordlists_calls_hcatCombinator3(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
for name in ["a.txt", "b.txt", "c.txt"]:
(tmp_path / name).write_text("word\n")
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
wl_arg = f"{tmp_path}/a.txt,{tmp_path}/b.txt,{tmp_path}/c.txt"
with patch("builtins.input", side_effect=["n", wl_arg]):
combinator3_crack(ctx)
inputs = ["n", f"{tmp_path}/a.txt", f"{tmp_path}/b.txt", f"{tmp_path}/c.txt", "", ""]
with patch("builtins.input", side_effect=inputs):
combinator_crack(ctx)
ctx.hcatCombinator3.assert_called_once()
ctx.hcatCombination.assert_not_called()
ctx.hcatCombinatorX.assert_not_called()
def test_aborts_with_fewer_than_3_wordlists(self, tmp_path):
def test_four_wordlists_calls_hcatCombinatorX(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
(tmp_path / "a.txt").write_text("word\n")
for name in ["a.txt", "b.txt", "c.txt", "d.txt"]:
(tmp_path / name).write_text("word\n")
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
wl_arg = f"{tmp_path}/a.txt,{tmp_path}/a.txt"
with patch("builtins.input", side_effect=["n", wl_arg]):
combinator3_crack(ctx)
inputs = [
"n",
f"{tmp_path}/a.txt",
f"{tmp_path}/b.txt",
f"{tmp_path}/c.txt",
f"{tmp_path}/d.txt",
"",
"",
]
with patch("builtins.input", side_effect=inputs):
combinator_crack(ctx)
ctx.hcatCombinatorX.assert_called_once()
ctx.hcatCombination.assert_not_called()
ctx.hcatCombinator3.assert_not_called()
def test_aborts_when_no_wordlists_provided(self, tmp_path):
def test_separator_forces_combinatorX_for_two_wordlists(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
for name in ["a.txt", "b.txt"]:
(tmp_path / name).write_text("word\n")
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
with patch("builtins.input", side_effect=["n", ""]):
combinator3_crack(ctx)
ctx.hcatCombinator3.assert_not_called()
inputs = ["n", f"{tmp_path}/a.txt", f"{tmp_path}/b.txt", "", "-"]
with patch("builtins.input", side_effect=inputs):
combinator_crack(ctx)
ctx.hcatCombinatorX.assert_called_once()
ctx.hcatCombination.assert_not_called()
def test_passes_exactly_3_wordlists(self, tmp_path):
def test_separator_forces_combinatorX_for_three_wordlists(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
for name in ["a.txt", "b.txt", "c.txt"]:
(tmp_path / name).write_text("word\n")
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
wl_arg = f"{tmp_path}/a.txt,{tmp_path}/b.txt,{tmp_path}/c.txt"
with patch("builtins.input", side_effect=["n", wl_arg]):
combinator3_crack(ctx)
call_args = ctx.hcatCombinator3.call_args
wordlists = call_args[0][2] if len(call_args[0]) >= 3 else call_args[1].get("wordlists")
assert len(wordlists) == 3
class TestCombinatorXCrack:
def test_calls_hcatCombinatorX_with_wordlists(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
for name in ["a.txt", "b.txt"]:
(tmp_path / name).write_text("word\n")
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
wl_arg = f"{tmp_path}/a.txt,{tmp_path}/b.txt"
with patch("builtins.input", side_effect=["n", wl_arg, ""]):
combinatorX_crack(ctx)
inputs = ["n", f"{tmp_path}/a.txt", f"{tmp_path}/b.txt", f"{tmp_path}/c.txt", "", "-"]
with patch("builtins.input", side_effect=inputs):
combinator_crack(ctx)
ctx.hcatCombinatorX.assert_called_once()
def test_passes_separator_to_hcatCombinatorX(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
for name in ["a.txt", "b.txt"]:
(tmp_path / name).write_text("word\n")
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
wl_arg = f"{tmp_path}/a.txt,{tmp_path}/b.txt"
with patch("builtins.input", side_effect=["n", wl_arg, "-"]):
combinatorX_crack(ctx)
call_args = ctx.hcatCombinatorX.call_args
# separator may be positional or keyword
positional_has_sep = len(call_args[0]) >= 4 and call_args[0][3] == "-"
keyword_has_sep = call_args[1].get("separator") == "-"
assert positional_has_sep or keyword_has_sep
ctx.hcatCombinator3.assert_not_called()
def test_aborts_with_fewer_than_2_wordlists(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
(tmp_path / "a.txt").write_text("word\n")
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
wl_arg = f"{tmp_path}/a.txt"
with patch("builtins.input", side_effect=["n", wl_arg, ""]):
combinatorX_crack(ctx)
inputs = ["n", f"{tmp_path}/a.txt", ""]
with patch("builtins.input", side_effect=inputs):
combinator_crack(ctx)
ctx.hcatCombination.assert_not_called()
ctx.hcatCombinator3.assert_not_called()
ctx.hcatCombinatorX.assert_not_called()
def test_no_separator_when_empty_input(self, tmp_path):
def test_aborts_when_no_wordlists_provided(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
for name in ["a.txt", "b.txt"]:
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
with patch("builtins.input", side_effect=["n", ""]):
combinator_crack(ctx)
ctx.hcatCombination.assert_not_called()
ctx.hcatCombinator3.assert_not_called()
ctx.hcatCombinatorX.assert_not_called()
def test_no_separator_passes_none_to_combinatorX(self, tmp_path):
ctx = _make_ctx()
ctx.hcatWordlists = str(tmp_path)
for name in ["a.txt", "b.txt", "c.txt", "d.txt"]:
(tmp_path / name).write_text("word\n")
ctx._resolve_wordlist_path.side_effect = lambda p, base: p
wl_arg = f"{tmp_path}/a.txt,{tmp_path}/b.txt"
with patch("builtins.input", side_effect=["n", wl_arg, ""]):
combinatorX_crack(ctx)
inputs = [
"n",
f"{tmp_path}/a.txt",
f"{tmp_path}/b.txt",
f"{tmp_path}/c.txt",
f"{tmp_path}/d.txt",
"",
"",
]
with patch("builtins.input", side_effect=inputs):
combinator_crack(ctx)
call_args = ctx.hcatCombinatorX.call_args
positional_sep = call_args[0][3] if len(call_args[0]) >= 4 else None
keyword_sep = call_args[1].get("separator")
# separator should be None or empty string when nothing entered
assert positional_sep in (None, "") and keyword_sep in (None, "")
class TestCombinatorSubmenuUpdated:
def test_submenu_has_combinator3_option(self):
def test_submenu_option1_dispatches_to_combinator_crack(self):
ctx = _make_ctx()
with patch("hate_crack.attacks.combinator3_crack") as mock_c3, patch(
"hate_crack.attacks.interactive_menu", side_effect=["5", "99"]
with patch("hate_crack.attacks.combinator_crack") as mock_c, patch(
"hate_crack.attacks.interactive_menu", side_effect=["1", "99"]
):
combinator_submenu(ctx)
mock_c3.assert_called_once_with(ctx)
mock_c.assert_called_once_with(ctx)
def test_submenu_has_combinatorX_option(self):
ctx = _make_ctx()
with patch("hate_crack.attacks.combinatorX_crack") as mock_cx, patch(
"hate_crack.attacks.interactive_menu", side_effect=["6", "99"]
):
combinator_submenu(ctx)
mock_cx.assert_called_once_with(ctx)
def test_submenu_items_include_new_attacks(self):
"""Verify the submenu item list advertises options 5 and 6."""
def test_submenu_has_no_separate_3plus_option(self):
"""Verify option 5 (3+) is removed - combinator is now unified under option 1."""
ctx = _make_ctx()
captured_items = []
@@ -140,5 +149,6 @@ class TestCombinatorSubmenuUpdated:
combinator_submenu(ctx)
keys = [item[0] for item in captured_items]
assert "5" in keys
assert "6" in keys
assert "1" in keys
assert "5" not in keys
assert "6" not in keys