From 69e6fb9cb31cc39f64828382c9b39a48b622e355 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 12:16:04 -0400 Subject: [PATCH] feat: add combinator3 and combinatorX attacks to combinator submenu Extends the combinator submenu (option 6) with two new attacks using hashcat-utils binaries that were already compiled but unused. - hcatCombinator3: 3-way wordlist combination via combinator3.bin piped to hashcat stdin - hcatCombinatorX: 2-8 wordlist combination via combinatorX.bin with optional --sepFill separator, piped to hashcat stdin - combinator3_crack handler: prompts for 3 comma-separated wordlist paths - combinatorX_crack handler: prompts for 2-8 paths plus optional separator - combinator_submenu updated with options 5 and 6 Closes #84, closes #85 --- README.md | 4 +- hate_crack/attacks.py | 113 +++++++++- hate_crack/main.py | 91 ++++++++ tests/test_combinator3_combinatorX.py | 144 +++++++++++++ tests/test_combinator_wrappers.py | 300 ++++++++++++++++++++++++++ 5 files changed, 649 insertions(+), 3 deletions(-) create mode 100644 tests/test_combinator3_combinatorX.py create mode 100644 tests/test_combinator_wrappers.py diff --git a/README.md b/README.md index 0a69f8b..7c17bda 100644 --- a/README.md +++ b/README.md @@ -763,11 +763,13 @@ Uses the Ordered Markov ENumerator (OMEN) to train a statistical password model * Model files and metadata are stored in `~/.hate_crack/omen/` for persistence across sessions #### Combinator Attacks Submenu -Opens an interactive submenu with four combinator attack variants (formerly at menu keys 10-12). Consolidates related attacks for cleaner menu organization: +Opens an interactive submenu with six combinator attack variants (formerly at menu keys 10-12). Consolidates related attacks for cleaner menu organization: - Combinator Attack - combines two wordlists - YOLO Combinator Attack - combines all permutations of multiple wordlists - Middle Combinator Attack - combines wordlists with an extra word in the middle - Thorough Combinator Attack - comprehensive combination of wordlists with rules +- Combinator3 Attack - combines exactly 3 wordlists using `combinator3.bin`, generating all `word1+word2+word3` combinations piped to hashcat +- CombinatorX Attack - combines 2-8 wordlists using `combinatorX.bin` with optional `--sepFill` separator character between word segments #### Ad-hoc Mask Attack Runs hashcat mask attack (mode 3) with a user-specified custom mask string. Allows fine-grained control over character-set brute forcing. diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index d06e250..7c38c62 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): @@ -427,6 +428,110 @@ 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) + + 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) < 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: + raw = input( + "\nEnter 3 wordlist file paths (comma-separated): " + ).strip() + if not raw: + print("No wordlists provided. Aborting combinator3 attack.") + return + + 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 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) + + 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 bandrel_method(ctx: Any) -> None: ctx.hcatBandrel(ctx.hcatHashType, ctx.hcatHashFile) @@ -621,13 +726,13 @@ 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"), ("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: @@ -642,3 +747,7 @@ 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 e36bc14..951a397 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -674,6 +674,8 @@ hcatDictionaryCount = 0 hcatMaskCount = 0 hcatFingerprintCount = 0 hcatCombinationCount = 0 +hcatCombinator3Count = 0 +hcatCombinatorXCount = 0 hcatHybridCount = 0 hcatExtraCount = 0 hcatRecycleCount = 0 @@ -1415,6 +1417,86 @@ def hcatCombination(hcatHashType, hcatHashFile, wordlists=None): hcatCombinationCount = lineCount(hcatHashFile + ".out") - hcatHashCracked +# Combinator3 Attack - 3-way combination via combinator3.bin piped to hashcat +def hcatCombinator3(hcatHashType, hcatHashFile, wordlists): + global hcatCombinator3Count + global hcatProcess + + if len(wordlists) < 3: + print("[!] Combinator3 attack requires exactly 3 wordlists.") + return + + combinator3_bin = os.path.join(hate_path, "hashcat-utils/bin/combinator3.bin") + generator_cmd = [combinator3_bin] + list(wordlists[:3]) + hashcat_cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + ] + hashcat_cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(hashcat_cmd) + generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE) + assert generator_proc.stdout is not None + hcatProcess = subprocess.Popen(hashcat_cmd, stdin=generator_proc.stdout) + generator_proc.stdout.close() + try: + hcatProcess.wait() + generator_proc.wait() + except KeyboardInterrupt: + print("Killing PID {0}...".format(str(hcatProcess.pid))) + hcatProcess.kill() + generator_proc.kill() + + hcatCombinator3Count = lineCount(hcatHashFile + ".out") - hcatHashCracked + + +# CombinatorX Attack - N-way combination (2-8 wordlists) via combinatorX.bin piped to hashcat +def hcatCombinatorX(hcatHashType, hcatHashFile, wordlists, separator=None): + global hcatCombinatorXCount + global hcatProcess + + if len(wordlists) < 2: + print("[!] CombinatorX attack requires at least 2 wordlists.") + return + + combinatorX_bin = os.path.join(hate_path, "hashcat-utils/bin/combinatorX.bin") + generator_cmd = [combinatorX_bin] + for i, f in enumerate(wordlists[:8], start=1): + generator_cmd += [f"--file{i}", f] + if separator: + generator_cmd += ["--sepFill", separator] + hashcat_cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + ] + hashcat_cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(hashcat_cmd) + generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE) + assert generator_proc.stdout is not None + hcatProcess = subprocess.Popen(hashcat_cmd, stdin=generator_proc.stdout) + generator_proc.stdout.close() + try: + hcatProcess.wait() + generator_proc.wait() + except KeyboardInterrupt: + print("Killing PID {0}...".format(str(hcatProcess.pid))) + hcatProcess.kill() + generator_proc.kill() + + hcatCombinatorXCount = lineCount(hcatHashFile + ".out") - hcatHashCracked + + # Hybrid Attack def hcatHybrid(hcatHashType, hcatHashFile, wordlists=None): global hcatHybridCount @@ -3301,6 +3383,15 @@ def middle_combinator(): return _attacks.middle_combinator(_attack_ctx()) +def combinator3_crack(): + return _attacks.combinator3_crack(_attack_ctx()) + + +def combinatorX_crack(): + return _attacks.combinatorX_crack(_attack_ctx()) + + + def combinator_submenu(): return _attacks.combinator_submenu(_attack_ctx()) diff --git a/tests/test_combinator3_combinatorX.py b/tests/test_combinator3_combinatorX.py new file mode 100644 index 0000000..267215e --- /dev/null +++ b/tests/test_combinator3_combinatorX.py @@ -0,0 +1,144 @@ +import os +from unittest.mock import MagicMock, patch + +import pytest + +from hate_crack.attacks import combinator3_crack, combinator_submenu, combinatorX_crack + + +def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"): + ctx = MagicMock() + ctx.hcatHashType = hash_type + ctx.hcatHashFile = hash_file + return ctx + + +class TestCombinator3Crack: + def test_calls_hcatCombinator3_with_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) + ctx.hcatCombinator3.assert_called_once() + + def test_aborts_with_fewer_than_3_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,{tmp_path}/a.txt" + with patch("builtins.input", side_effect=["n", wl_arg]): + combinator3_crack(ctx) + ctx.hcatCombinator3.assert_not_called() + + def test_aborts_when_no_wordlists_provided(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + 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() + + def test_passes_exactly_3_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) + 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 + + 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) + ctx.hcatCombinatorX.assert_not_called() + + def test_no_separator_when_empty_input(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 + 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): + ctx = _make_ctx() + with patch("hate_crack.attacks.combinator3_crack") as mock_c3, patch( + "hate_crack.attacks.interactive_menu", side_effect=["5", "99"] + ): + combinator_submenu(ctx) + mock_c3.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.""" + ctx = _make_ctx() + captured_items = [] + + def capture_menu(items, **kwargs): + captured_items.extend(items) + return "99" + + with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu): + combinator_submenu(ctx) + + keys = [item[0] for item in captured_items] + assert "5" in keys + assert "6" in keys diff --git a/tests/test_combinator_wrappers.py b/tests/test_combinator_wrappers.py new file mode 100644 index 0000000..3ace009 --- /dev/null +++ b/tests/test_combinator_wrappers.py @@ -0,0 +1,300 @@ +"""Tests for hcatCombinator3 and hcatCombinatorX hashcat wrapper functions.""" + +from unittest.mock import MagicMock, patch + +import pytest + + +def _make_mock_proc(wait_side_effect=None): + proc = MagicMock() + proc.stdout = MagicMock() + if wait_side_effect is not None: + proc.wait.side_effect = wait_side_effect + else: + proc.wait.return_value = None + proc.pid = 12345 + return proc + + +@pytest.fixture +def main_module(hc_module): + return hc_module._main + + +class TestHcatCombinator3: + def test_calls_combinator3_bin_with_three_files(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(3): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinator3" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinator3("1000", hash_file, wls) + + calls = mock_popen.call_args_list + assert len(calls) == 2 + combinator_cmd = calls[0][0][0] + assert "combinator3" in combinator_cmd[0] + assert wls[0] in combinator_cmd + assert wls[1] in combinator_cmd + assert wls[2] in combinator_cmd + + def test_pipes_stdout_to_hashcat_stdin(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(3): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinator3" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinator3("1000", hash_file, wls) + + calls = mock_popen.call_args_list + hashcat_call_kwargs = calls[1][1] + assert hashcat_call_kwargs.get("stdin") == combinator_proc.stdout + + def test_aborts_with_fewer_than_3_wordlists(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wl1 = str(tmp_path / "w1.txt") + wl2 = str(tmp_path / "w2.txt") + for p in [wl1, wl2]: + open(p, "w").close() + + with patch("hate_crack.main.subprocess.Popen") as mock_popen: + main_module.hcatCombinator3("1000", hash_file, [wl1, wl2]) + + mock_popen.assert_not_called() + + def test_keyboard_interrupt_kills_both_processes(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(3): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc(wait_side_effect=KeyboardInterrupt()) + + def popen_side_effect(cmd, **kwargs): + if "combinator3" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect), + ): + main_module.hcatCombinator3("1000", hash_file, wls) + + hashcat_proc.kill.assert_called_once() + combinator_proc.kill.assert_called_once() + + +class TestHcatCombinatorX: + def test_calls_combinatorX_bin_with_file_flags(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(2): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinatorX" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinatorX("1000", hash_file, wls) + + calls = mock_popen.call_args_list + assert len(calls) == 2 + combinator_cmd = calls[0][0][0] + assert "combinatorX" in combinator_cmd[0] + assert "--file1" in combinator_cmd + assert "--file2" in combinator_cmd + + def test_passes_sepfill_when_separator_given(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(2): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinatorX" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinatorX("1000", hash_file, wls, separator="-") + + combinator_cmd = mock_popen.call_args_list[0][0][0] + assert "--sepFill" in combinator_cmd + sep_idx = combinator_cmd.index("--sepFill") + assert combinator_cmd[sep_idx + 1] == "-" + + def test_no_sepfill_when_separator_is_none(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(2): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinatorX" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinatorX("1000", hash_file, wls, separator=None) + + combinator_cmd = mock_popen.call_args_list[0][0][0] + assert "--sepFill" not in combinator_cmd + + def test_aborts_with_fewer_than_2_wordlists(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wl1 = str(tmp_path / "w1.txt") + open(wl1, "w").close() + + with patch("hate_crack.main.subprocess.Popen") as mock_popen: + main_module.hcatCombinatorX("1000", hash_file, [wl1]) + + mock_popen.assert_not_called() + + def test_supports_up_to_8_wordlists(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(8): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinatorX" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinatorX("1000", hash_file, wls) + + combinator_cmd = mock_popen.call_args_list[0][0][0] + for i in range(1, 9): + assert f"--file{i}" in combinator_cmd + + def test_keyboard_interrupt_kills_both_processes(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(2): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc(wait_side_effect=KeyboardInterrupt()) + + def popen_side_effect(cmd, **kwargs): + if "combinatorX" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect), + ): + main_module.hcatCombinatorX("1000", hash_file, wls) + + hashcat_proc.kill.assert_called_once() + combinator_proc.kill.assert_called_once()