From 7a7401266b5812a44ce567adb1d2e4d16dbd337b Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 14:18:25 -0400 Subject: [PATCH] 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 --- config.json.example | 2 + hate_crack/attacks.py | 226 ++++++++------------------ hate_crack/main.py | 34 ++-- tests/test_attacks_behavior.py | 9 +- tests/test_combinator3_combinatorX.py | 164 ++++++++++--------- 5 files changed, 181 insertions(+), 254 deletions(-) diff --git a/config.json.example b/config.json.example index a2804ca..5431016 100644 --- a/config.json.example +++ b/config.json.example @@ -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", diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 7c38c62..bbd40d4 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -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) diff --git a/hate_crack/main.py b/hate_crack/main.py index 951a397..7f93bf7 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -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()) diff --git a/tests/test_attacks_behavior.py b/tests/test_attacks_behavior.py index f574997..4096819 100644 --- a/tests/test_attacks_behavior.py +++ b/tests/test_attacks_behavior.py @@ -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: diff --git a/tests/test_combinator3_combinatorX.py b/tests/test_combinator3_combinatorX.py index 267215e..25fd5b5 100644 --- a/tests/test_combinator3_combinatorX.py +++ b/tests/test_combinator3_combinatorX.py @@ -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