From 09d4acd8cac1db85e14287e4e7d50e4c7bc5dbb2 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Sat, 25 Apr 2026 20:06:45 -0400 Subject: [PATCH 01/31] feat(api): route torrent files through daemon watch dir, not wordlist dir - Suppress transmission-daemon stdout/stderr (background process) - Create a watch/ subdir in the session temp config dir; start daemon with --watch-dir instead of --no-watch-dir - Replace transmission-remote -a with shutil.copy2 into the watch dir, then poll until the daemon picks it up - Save .torrent metadata files to tempfile.gettempdir() instead of the wordlist directory; update cleanup to match - Update TransmissionSession.add tests to cover the new watch-dir flow Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/api.py | 65 ++++++++++++++----------------------- tests/test_api_downloads.py | 62 +++++++++++++++++------------------ 2 files changed, 53 insertions(+), 74 deletions(-) diff --git a/hate_crack/api.py b/hate_crack/api.py index f40121b..34c94bf 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -2,10 +2,11 @@ import concurrent.futures import json import sys import os +import shutil +import tempfile import threading import time from queue import Queue -import shutil from typing import Callable, Optional, Tuple import requests # type: ignore[import-untyped] @@ -258,6 +259,7 @@ class TransmissionSession: self.startup_timeout = startup_timeout self.shutdown_timeout = shutdown_timeout self._cfg_dir = "" + self._watch_dir = "" self._port = 0 self._rpc = "" self._proc = None @@ -266,9 +268,10 @@ class TransmissionSession: def __enter__(self): import atexit import subprocess - import tempfile self._cfg_dir = tempfile.mkdtemp(prefix="hate_crack_transmission_") + self._watch_dir = os.path.join(self._cfg_dir, "watch") + os.makedirs(self._watch_dir, exist_ok=True) self._port = _pick_free_port() self._rpc = f"127.0.0.1:{self._port}" self._proc = subprocess.Popen( @@ -285,8 +288,11 @@ class TransmissionSession: "--download-dir", self.save_dir, "--no-portmap", - "--no-watch-dir", - ] + "--watch-dir", + self._watch_dir, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) deadline = time.monotonic() + self.startup_timeout while time.monotonic() < deadline: @@ -339,32 +345,15 @@ class TransmissionSession: return None def add(self, torrent_path: str) -> int: - import re - import subprocess - before_ids = {e["id"] for e in self.list()} - result = subprocess.run( - [ - "transmission-remote", - self._rpc, - "--no-auth", - "-a", - torrent_path, - ], - capture_output=True, - text=True, - ) - out = result.stdout or "" - m = re.search(r"Added torrent.*\n.*ID:\s*(\d+)", out) - if m: - return int(m.group(1)) - m = re.search(r"torrent added\s*\(id\s+(\d+)\)", out, re.IGNORECASE) - if m: - return int(m.group(1)) - after_entries = self.list() - new_ids = [e["id"] for e in after_entries if e["id"] not in before_ids] - if new_ids: - return new_ids[0] + shutil.copy2(torrent_path, self._watch_dir) + deadline = time.monotonic() + 10.0 + while time.monotonic() < deadline: + after_entries = self.list() + new_ids = [e["id"] for e in after_entries if e["id"] not in before_ids] + if new_ids: + return new_ids[0] + time.sleep(0.5) raise RuntimeError(f"Failed to add torrent: {torrent_path}") def list(self) -> list: @@ -582,9 +571,9 @@ def get_hcat_potfile_args(): def cleanup_torrent_files(directory=None): - """Remove stray .torrent files from the wordlists directory on graceful exit.""" + """Remove stray .torrent files left in the system temp directory on graceful exit.""" if directory is None: - directory = get_hcat_wordlists_dir() + directory = tempfile.gettempdir() try: for name in os.listdir(directory): if name.endswith(".torrent"): @@ -791,18 +780,12 @@ def fetch_torrent_metadata(torrent_url, save_dir=None, wordlist_id=None): """Download the .torrent metadata file from Weakpass and return its local path. Returns the path to the saved .torrent file, or None on failure. + The .torrent file is stored in the system temp directory, not the wordlist dir. """ register_torrent_cleanup() - if not save_dir: - save_dir = get_hcat_wordlists_dir() - else: - save_dir = os.path.expanduser(save_dir) - if not os.path.isabs(save_dir): - save_dir = os.path.join( - os.path.dirname(os.path.abspath(__file__)), save_dir - ) - os.makedirs(save_dir, exist_ok=True) + torrent_dir = tempfile.gettempdir() + os.makedirs(torrent_dir, exist_ok=True) # Optionally include hashmob_api_key in headers if present headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" @@ -899,7 +882,7 @@ def fetch_torrent_metadata(torrent_url, save_dir=None, wordlist_id=None): r2 = requests.get(torrent_link, headers=headers, stream=True) content_type = r2.headers.get("Content-Type", "") local_filename = os.path.join( - save_dir, filename if filename.endswith(".torrent") else filename + ".torrent" + torrent_dir, filename if filename.endswith(".torrent") else filename + ".torrent" ) if r2.status_code == 200 and not content_type.startswith("text/html"): with open(local_filename, "wb") as f: diff --git a/tests/test_api_downloads.py b/tests/test_api_downloads.py index ef27a5a..cb21d7e 100644 --- a/tests/test_api_downloads.py +++ b/tests/test_api_downloads.py @@ -140,48 +140,44 @@ class TestTransmissionSession: with pytest.raises(RuntimeError, match="Transmission daemon failed"): ts.__enter__() - def test_add_parses_id_from_stdout(self, tmp_path): + def test_add_copies_to_watch_dir_and_returns_new_id(self, tmp_path): ts = TransmissionSession(str(tmp_path)) ts._rpc = "127.0.0.1:9999" - result = MagicMock( - returncode=0, stdout="Added torrent foo.torrent\n ID: 7\n", stderr="" - ) - with patch("subprocess.run", return_value=result): - tid = ts.add("/tmp/foo.torrent") - assert tid == 7 - - def test_add_parses_lowercase_alt_format(self, tmp_path): - ts = TransmissionSession(str(tmp_path)) - ts._rpc = "127.0.0.1:9999" - result = MagicMock( - returncode=0, stdout="torrent added (id 42)\n", stderr="" - ) - with patch("subprocess.run", return_value=result): - tid = ts.add("/tmp/foo.torrent") - assert tid == 42 - - def test_add_falls_back_to_list(self, tmp_path): - ts = TransmissionSession(str(tmp_path)) - ts._rpc = "127.0.0.1:9999" - result = MagicMock(returncode=0, stdout="garbage output\n", stderr="") - # Before: IDs 3 and 5 exist. After: ID 7 appears as the newly added torrent. + ts._watch_dir = str(tmp_path / "watch") + # Before: IDs 3 and 5. After first poll: ID 7 appears. list_calls = iter([ - [{"id": 3}, {"id": 5}], # before snapshot - [{"id": 3}, {"id": 5}, {"id": 7}], # after snapshot + [{"id": 3}, {"id": 5}], + [{"id": 3}, {"id": 5}, {"id": 7}], ]) - with patch("subprocess.run", return_value=result), patch.object( - ts, "list", side_effect=list_calls - ): + with patch("shutil.copy2"), patch.object(ts, "list", side_effect=list_calls), \ + patch("time.sleep"), patch("time.monotonic", side_effect=[0.0, 1.0]): tid = ts.add("/tmp/foo.torrent") assert tid == 7 - def test_add_raises_when_list_empty(self, tmp_path): + def test_add_polls_until_daemon_picks_up_torrent(self, tmp_path): ts = TransmissionSession(str(tmp_path)) ts._rpc = "127.0.0.1:9999" - result = MagicMock(returncode=0, stdout="garbage\n", stderr="") - with patch("subprocess.run", return_value=result), patch.object( - ts, "list", return_value=[] - ): + ts._watch_dir = str(tmp_path / "watch") + # First two polls: no new ID. Third poll: ID 9 appears. + list_calls = iter([ + [{"id": 1}], + [{"id": 1}], + [{"id": 1}], + [{"id": 1}, {"id": 9}], + ]) + monotonic_vals = iter([0.0, 1.0, 2.0, 3.0, 4.0]) + with patch("shutil.copy2"), patch.object(ts, "list", side_effect=list_calls), \ + patch("time.sleep"), patch("time.monotonic", side_effect=monotonic_vals): + tid = ts.add("/tmp/foo.torrent") + assert tid == 9 + + def test_add_raises_on_timeout(self, tmp_path): + ts = TransmissionSession(str(tmp_path)) + ts._rpc = "127.0.0.1:9999" + ts._watch_dir = str(tmp_path / "watch") + # list never returns a new ID; monotonic jumps past the 10s deadline. + with patch("shutil.copy2"), patch.object(ts, "list", return_value=[{"id": 1}]), \ + patch("time.sleep"), patch("time.monotonic", side_effect=[0.0, 100.0]): with pytest.raises(RuntimeError): ts.add("/tmp/foo.torrent") From eedf83447ca19e41fbdab6c516718f92c5f8fabc Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Sat, 25 Apr 2026 20:08:55 -0400 Subject: [PATCH 02/31] docs(readme): add v2.9.3 version history entry Co-Authored-By: Claude Sonnet 4.6 --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 0b3a2bb..06991ae 100644 --- a/README.md +++ b/README.md @@ -918,6 +918,10 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi ------------------------------------------------------------------- ### Version History +Version 2.9.3 + - Routed `.torrent` metadata files through a daemon-watched temp directory instead of the wordlist directory; actual wordlist content still lands in the configured wordlist directory + - Suppressed `transmission-daemon` stdout/stderr so daemon log messages no longer appear in the terminal + Version 2.5.0 - Added tab autocomplete to all file and directory path prompts in the Wordlist Tools submenu (option 80) - Restored `hcatOptimizedWordlists` config key (directory for pre-optimized wordlists); defaults to `./optimized_wordlists`, falls back to `hcatWordlists` if not found From b198e0327bbe46da4280b6342b82598ec840df4d Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Sat, 25 Apr 2026 20:27:33 -0400 Subject: [PATCH 03/31] fix(api): increase watch-dir polling timeout from 10s to 30s Transmission's watch dir scanner runs every ~10s, so a 10s deadline could expire before the first scan completes. 30s gives 2-3 scan cycles of headroom. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 3 +++ hate_crack/api.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 06991ae..de289eb 100644 --- a/README.md +++ b/README.md @@ -918,6 +918,9 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi ------------------------------------------------------------------- ### Version History +Version 2.9.4 + - Fixed torrent add timeout: increased watch-dir polling window from 10s to 30s to give transmission's scanner (which runs every ~10s) enough time to pick up new .torrent files + Version 2.9.3 - Routed `.torrent` metadata files through a daemon-watched temp directory instead of the wordlist directory; actual wordlist content still lands in the configured wordlist directory - Suppressed `transmission-daemon` stdout/stderr so daemon log messages no longer appear in the terminal diff --git a/hate_crack/api.py b/hate_crack/api.py index 34c94bf..5adffdc 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -347,7 +347,7 @@ class TransmissionSession: def add(self, torrent_path: str) -> int: before_ids = {e["id"] for e in self.list()} shutil.copy2(torrent_path, self._watch_dir) - deadline = time.monotonic() + 10.0 + deadline = time.monotonic() + 30.0 while time.monotonic() < deadline: after_entries = self.list() new_ids = [e["id"] for e in after_entries if e["id"] not in before_ids] From 9d2031016bd33850834b69fb005f582fb5f16303 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Sat, 25 Apr 2026 20:28:22 -0400 Subject: [PATCH 04/31] fix(api): store torrent files in /tmp/hate_crack/ not /tmp/ Co-Authored-By: Claude Sonnet 4.6 --- README.md | 3 +++ hate_crack/api.py | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index de289eb..b2ed62d 100644 --- a/README.md +++ b/README.md @@ -918,6 +918,9 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi ------------------------------------------------------------------- ### Version History +Version 2.9.5 + - Store downloaded `.torrent` files in `/tmp/hate_crack/` instead of `/tmp/` root + Version 2.9.4 - Fixed torrent add timeout: increased watch-dir polling window from 10s to 30s to give transmission's scanner (which runs every ~10s) enough time to pick up new .torrent files diff --git a/hate_crack/api.py b/hate_crack/api.py index 5adffdc..cdfa697 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -571,9 +571,9 @@ def get_hcat_potfile_args(): def cleanup_torrent_files(directory=None): - """Remove stray .torrent files left in the system temp directory on graceful exit.""" + """Remove stray .torrent files left in the hate_crack temp directory on graceful exit.""" if directory is None: - directory = tempfile.gettempdir() + directory = os.path.join(tempfile.gettempdir(), "hate_crack") try: for name in os.listdir(directory): if name.endswith(".torrent"): @@ -784,7 +784,7 @@ def fetch_torrent_metadata(torrent_url, save_dir=None, wordlist_id=None): """ register_torrent_cleanup() - torrent_dir = tempfile.gettempdir() + torrent_dir = os.path.join(tempfile.gettempdir(), "hate_crack") os.makedirs(torrent_dir, exist_ok=True) # Optionally include hashmob_api_key in headers if present headers = { From 951bc6f94519f101300d81049e2fffc579ac96ea Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Sat, 25 Apr 2026 20:38:51 -0400 Subject: [PATCH 05/31] docs(readme): consolidate torrent changes under v2.10.0 Co-Authored-By: Claude Sonnet 4.6 --- README.md | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b2ed62d..6be7bd1 100644 --- a/README.md +++ b/README.md @@ -918,16 +918,12 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi ------------------------------------------------------------------- ### Version History -Version 2.9.5 +Version 2.10.0 + - Transmission daemon now watches a dedicated temp directory (`/tmp/hate_crack/`) for new `.torrent` files; wordlist content still downloads to the configured wordlist directory + - Suppressed `transmission-daemon` stdout/stderr so daemon log output no longer appears in the terminal + - Increased watch-dir polling window to 30s to account for transmission's ~10s scan interval - Store downloaded `.torrent` files in `/tmp/hate_crack/` instead of `/tmp/` root -Version 2.9.4 - - Fixed torrent add timeout: increased watch-dir polling window from 10s to 30s to give transmission's scanner (which runs every ~10s) enough time to pick up new .torrent files - -Version 2.9.3 - - Routed `.torrent` metadata files through a daemon-watched temp directory instead of the wordlist directory; actual wordlist content still lands in the configured wordlist directory - - Suppressed `transmission-daemon` stdout/stderr so daemon log messages no longer appear in the terminal - Version 2.5.0 - Added tab autocomplete to all file and directory path prompts in the Wordlist Tools submenu (option 80) - Restored `hcatOptimizedWordlists` config key (directory for pre-optimized wordlists); defaults to `./optimized_wordlists`, falls back to `hcatWordlists` if not found From 144941d0ede3d98ae4278c4c760e9d5ebe661cb7 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Sat, 25 Apr 2026 20:41:45 -0400 Subject: [PATCH 06/31] docs(readme): correct version history to v2.9.3 Co-Authored-By: Claude Sonnet 4.6 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6be7bd1..284af3c 100644 --- a/README.md +++ b/README.md @@ -918,8 +918,8 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi ------------------------------------------------------------------- ### Version History -Version 2.10.0 - - Transmission daemon now watches a dedicated temp directory (`/tmp/hate_crack/`) for new `.torrent` files; wordlist content still downloads to the configured wordlist directory +Version 2.9.3 + - Transmission daemon now watches `/tmp/hate_crack/` for new `.torrent` files; wordlist content still downloads to the configured wordlist directory - Suppressed `transmission-daemon` stdout/stderr so daemon log output no longer appears in the terminal - Increased watch-dir polling window to 30s to account for transmission's ~10s scan interval - Store downloaded `.torrent` files in `/tmp/hate_crack/` instead of `/tmp/` root From 3aa7138c9cdc13969f5f0d7ab2998605dd0fae39 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Sat, 25 Apr 2026 22:56:13 -0400 Subject: [PATCH 07/31] fix(docker): fix E2E torrent test and transmission-remote compatibility - Add transmission-cli package (provides transmission-remote binary) - Fix SETUPTOOLS_SCM_PRETEND_VERSION placement before RUN make install - Fix PATH to use /root/.local/bin and include venv bin - Switch TransmissionSession.add() from watch-dir polling to transmission-remote -a - Remove --no-auth flag (unrecognized in transmission-remote 4.1.0) - Add test_docker_torrent_downloads_wordlists E2E test Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile.test | 5 +++- hate_crack/api.py | 42 +++++++++++++++++----------- tests/test_api_downloads.py | 43 ++++++++++++++--------------- tests/test_docker_script_install.py | 42 ++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 40 deletions(-) diff --git a/Dockerfile.test b/Dockerfile.test index 775be7d..869bc53 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -13,6 +13,7 @@ RUN apt-get update \ ocl-icd-libopencl1 \ pocl-opencl-icd \ p7zip-full \ + transmission-cli \ transmission-daemon \ && rm -rf /var/lib/apt/lists/* @@ -20,9 +21,11 @@ RUN python -m pip install -q uv==0.9.28 COPY . /workspace +ENV SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 + RUN make install -ENV PATH="${HOME}/.local/bin:${PATH}" +ENV PATH="/workspace/.venv/bin:/root/.local/bin:${PATH}" ENV HATE_CRACK_SKIP_INIT=1 CMD ["bash", "-lc", "${HOME}/.local/bin/hate_crack --help >/tmp/hc_help.txt && ./hate_crack.py --help >/tmp/hc_script_help.txt"] diff --git a/hate_crack/api.py b/hate_crack/api.py index cdfa697..e80843e 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -259,7 +259,6 @@ class TransmissionSession: self.startup_timeout = startup_timeout self.shutdown_timeout = shutdown_timeout self._cfg_dir = "" - self._watch_dir = "" self._port = 0 self._rpc = "" self._proc = None @@ -270,8 +269,6 @@ class TransmissionSession: import subprocess self._cfg_dir = tempfile.mkdtemp(prefix="hate_crack_transmission_") - self._watch_dir = os.path.join(self._cfg_dir, "watch") - os.makedirs(self._watch_dir, exist_ok=True) self._port = _pick_free_port() self._rpc = f"127.0.0.1:{self._port}" self._proc = subprocess.Popen( @@ -288,8 +285,7 @@ class TransmissionSession: "--download-dir", self.save_dir, "--no-portmap", - "--watch-dir", - self._watch_dir, + "--no-watch-dir", ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -345,22 +341,38 @@ class TransmissionSession: return None def add(self, torrent_path: str) -> int: + import re + import subprocess + before_ids = {e["id"] for e in self.list()} - shutil.copy2(torrent_path, self._watch_dir) - deadline = time.monotonic() + 30.0 - while time.monotonic() < deadline: - after_entries = self.list() - new_ids = [e["id"] for e in after_entries if e["id"] not in before_ids] - if new_ids: - return new_ids[0] - time.sleep(0.5) + result = subprocess.run( + [ + "transmission-remote", + self._rpc, + "-a", + torrent_path, + ], + capture_output=True, + text=True, + ) + out = result.stdout or "" + m = re.search(r"Added torrent.*\n.*ID:\s*(\d+)", out) + if m: + return int(m.group(1)) + m = re.search(r"torrent added\s*\(id\s+(\d+)\)", out, re.IGNORECASE) + if m: + return int(m.group(1)) + after_entries = self.list() + new_ids = [e["id"] for e in after_entries if e["id"] not in before_ids] + if new_ids: + return new_ids[0] raise RuntimeError(f"Failed to add torrent: {torrent_path}") def list(self) -> list: import subprocess result = subprocess.run( - ["transmission-remote", self._rpc, "--no-auth", "-l"], + ["transmission-remote", self._rpc, "-l"], capture_output=True, text=True, ) @@ -417,7 +429,6 @@ class TransmissionSession: [ "transmission-remote", self._rpc, - "--no-auth", f"-t{torrent_id}", "--info-files", ], @@ -461,7 +472,6 @@ class TransmissionSession: [ "transmission-remote", self._rpc, - "--no-auth", f"-t{torrent_id}", "--remove", ], diff --git a/tests/test_api_downloads.py b/tests/test_api_downloads.py index cb21d7e..42d56bc 100644 --- a/tests/test_api_downloads.py +++ b/tests/test_api_downloads.py @@ -140,44 +140,41 @@ class TestTransmissionSession: with pytest.raises(RuntimeError, match="Transmission daemon failed"): ts.__enter__() - def test_add_copies_to_watch_dir_and_returns_new_id(self, tmp_path): + def test_add_uses_transmission_remote_and_returns_new_id(self, tmp_path): ts = TransmissionSession(str(tmp_path)) ts._rpc = "127.0.0.1:9999" - ts._watch_dir = str(tmp_path / "watch") - # Before: IDs 3 and 5. After first poll: ID 7 appears. + # Before: IDs 3 and 5. After add: ID 7 appears. list_calls = iter([ [{"id": 3}, {"id": 5}], [{"id": 3}, {"id": 5}, {"id": 7}], ]) - with patch("shutil.copy2"), patch.object(ts, "list", side_effect=list_calls), \ - patch("time.sleep"), patch("time.monotonic", side_effect=[0.0, 1.0]): + run_result = MagicMock(returncode=0, stdout="", stderr="") + with patch("subprocess.run", return_value=run_result), \ + patch.object(ts, "list", side_effect=list_calls): tid = ts.add("/tmp/foo.torrent") assert tid == 7 - def test_add_polls_until_daemon_picks_up_torrent(self, tmp_path): + def test_add_parses_id_from_output(self, tmp_path): ts = TransmissionSession(str(tmp_path)) ts._rpc = "127.0.0.1:9999" - ts._watch_dir = str(tmp_path / "watch") - # First two polls: no new ID. Third poll: ID 9 appears. - list_calls = iter([ - [{"id": 1}], - [{"id": 1}], - [{"id": 1}], - [{"id": 1}, {"id": 9}], - ]) - monotonic_vals = iter([0.0, 1.0, 2.0, 3.0, 4.0]) - with patch("shutil.copy2"), patch.object(ts, "list", side_effect=list_calls), \ - patch("time.sleep"), patch("time.monotonic", side_effect=monotonic_vals): + before_list = [{"id": 1}] + run_result = MagicMock( + returncode=0, + stdout="torrent added (id 42)\n", + stderr="", + ) + with patch("subprocess.run", return_value=run_result), \ + patch.object(ts, "list", return_value=before_list): tid = ts.add("/tmp/foo.torrent") - assert tid == 9 + assert tid == 42 - def test_add_raises_on_timeout(self, tmp_path): + def test_add_raises_when_torrent_not_added(self, tmp_path): ts = TransmissionSession(str(tmp_path)) ts._rpc = "127.0.0.1:9999" - ts._watch_dir = str(tmp_path / "watch") - # list never returns a new ID; monotonic jumps past the 10s deadline. - with patch("shutil.copy2"), patch.object(ts, "list", return_value=[{"id": 1}]), \ - patch("time.sleep"), patch("time.monotonic", side_effect=[0.0, 100.0]): + # list returns the same IDs before and after; output has no ID. + run_result = MagicMock(returncode=1, stdout="", stderr="error") + with patch("subprocess.run", return_value=run_result), \ + patch.object(ts, "list", return_value=[{"id": 1}]): with pytest.raises(RuntimeError): ts.add("/tmp/foo.torrent") diff --git a/tests/test_docker_script_install.py b/tests/test_docker_script_install.py index 1df1df7..4a26082 100644 --- a/tests/test_docker_script_install.py +++ b/tests/test_docker_script_install.py @@ -97,3 +97,45 @@ def test_docker_hashcat_cracks_simple_password(docker_image): assert run.returncode == 0, ( f"Docker hashcat crack failed. stdout={run.stdout} stderr={run.stderr}" ) + + +@pytest.mark.timeout(300) +def test_docker_torrent_downloads_wordlists(docker_image, tmp_path): + downloads_dir = tmp_path / "downloads" + downloads_dir.mkdir() + + py_cmd = ( + "from hate_crack.api import fetch_torrent_metadata, run_torrent_session; " + "t1 = fetch_torrent_metadata('ignis-10K.txt'); " + "t2 = fetch_torrent_metadata('hashmob.net_2025.micro.found'); " + "files = [f for f in (t1, t2) if f]; " + "run_torrent_session(files, '/downloads')" + ) + + try: + run = subprocess.run( + [ + "docker", "run", "--rm", + "-v", f"{downloads_dir}:/downloads", + docker_image, + "bash", "-lc", f"/workspace/.venv/bin/python -c \"{py_cmd}\"", + ], + capture_output=True, + text=True, + timeout=300, + ) + except subprocess.TimeoutExpired as exc: + pytest.fail(f"Docker torrent test timed out after {exc.timeout}s") + + assert run.returncode == 0, ( + f"Torrent session failed. stdout={run.stdout} stderr={run.stderr}" + ) + + ignis10k = downloads_dir / "ignis-10K.txt" + micro = downloads_dir / "hashmob.net_2025.micro.found" + assert ignis10k.exists() and ignis10k.stat().st_size > 0, ( + f"ignis-10K.txt missing/empty. stdout={run.stdout} stderr={run.stderr}" + ) + assert micro.exists() and micro.stat().st_size > 0, ( + f"hashmob.net_2025.micro.found missing/empty. stdout={run.stdout} stderr={run.stderr}" + ) From 7d7860d28fddaa6771906fa1331b87b2a11a1d2b Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Sun, 3 May 2026 14:37:08 -0400 Subject: [PATCH 08/31] build(deps): make simple-term-menu a default dependency Move simple-term-menu from the [tui] optional extra into the main dependencies list so arrow-key menu navigation works out of the box. Users can still opt into the plain numbered menu with HATE_CRACK_PLAIN_MENU=1. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 12 +++--------- pyproject.toml | 4 +--- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 284af3c..ccb72e8 100644 --- a/README.md +++ b/README.md @@ -358,17 +358,11 @@ This installs hooks defined in `prek.toml` using the pre-commit local-repo TOML Note: prek 0.3.3 expects `repos = [...]` at the top level. The old `[hooks.] commands = [...]` format is not supported. -### Arrow-Key Menu Navigation (Optional) +### Arrow-Key Menu Navigation -Install the `[tui]` extra to enable arrow-key menu navigation via `simple-term-menu`: +Arrow-key menu navigation is enabled by default via the `simple-term-menu` dependency. When running in a terminal (TTY), menus render with arrow-key navigation and number-key shortcuts. -```bash -uv pip install '.[tui]' -``` - -When installed and running in a terminal (TTY), menus render with arrow-key navigation and number-key shortcuts. Without it, the classic numbered `print()` + `input()` menu is used. - -To force the plain numbered menu even when `simple-term-menu` is installed, set `HATE_CRACK_PLAIN_MENU=1`. +To force the classic numbered `print()` + `input()` menu, set `HATE_CRACK_PLAIN_MENU=1`. ### Dev Dependencies diff --git a/pyproject.toml b/pyproject.toml index e9301e0..72a0ba9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,14 +13,12 @@ dependencies = [ "beautifulsoup4>=4.12.0", "openpyxl>=3.0.0", "packaging>=21.0", + "simple-term-menu==1.6.6", ] [project.scripts] hate_crack = "hate_crack.__main__:main" -[project.optional-dependencies] -tui = ["simple-term-menu==1.6.6"] - [tool.setuptools.packages.find] include = ["hate_crack*"] From 7a768c81b67fc2db2636be63b4fdb3764955ac9d Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 08:43:05 -0400 Subject: [PATCH 09/31] feat(pcfg): vendor pcfg_cracker as submodule --- .gitmodules | 4 ++++ Makefile | 1 + pcfg_cracker | 1 + 3 files changed, 6 insertions(+) create mode 160000 pcfg_cracker diff --git a/.gitmodules b/.gitmodules index 68ef19c..5ae216f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -14,3 +14,7 @@ path = princeprocessor url = https://github.com/hashcat/princeprocessor.git ignore = dirty +[submodule "pcfg_cracker"] + path = pcfg_cracker + url = https://github.com/lakiw/pcfg_cracker.git + ignore = dirty diff --git a/Makefile b/Makefile index 8ab3887..203c6f4 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,7 @@ submodules-pre: @test -d hashcat-utils || { echo "Error: missing required directory: hashcat-utils"; exit 1; } @test -d princeprocessor || { echo "Error: missing required directory: princeprocessor"; exit 1; } @test -d omen || { echo "Warning: missing directory: omen (OMEN attacks will not be available)"; } + @test -d pcfg_cracker || { echo "Warning: missing directory: pcfg_cracker (PCFG attacks will not be available)"; } @# Generate per-length expander sources (expander8.c..expander36.c) and patch @# hashcat-utils Makefiles to compile them. Skips if expander8.c already exists. @for base in hashcat-utils; do \ diff --git a/pcfg_cracker b/pcfg_cracker new file mode 160000 index 0000000..b04bbda --- /dev/null +++ b/pcfg_cracker @@ -0,0 +1 @@ +Subproject commit b04bbdadfe8928fd1287fa73ad1aa46a297ff83a From e1ac5eee7de98e2da9c0d0e9a98c87a2e0f41491 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 08:45:09 -0400 Subject: [PATCH 10/31] feat(pcfg): add pcfgRuleset, pcfgMaxCandidates, pcfgPrinceLingMaxCandidates config keys --- config.json.example | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/config.json.example b/config.json.example index 27bf100..c696519 100644 --- a/config.json.example +++ b/config.json.example @@ -29,6 +29,9 @@ "ollamaNumCtx": 2048, "omenTrainingList": "rockyou.txt", "omenMaxCandidates": 50000000, + "pcfgRuleset": "DEFAULT", + "pcfgMaxCandidates": 50000000, + "pcfgPrinceLingMaxCandidates": 10000000, "check_for_updates": true, "optimizedKernelAttacks": [ "hcatDictionary", "hcatQuickDictionary", "hcatBandrel", "hcatGoodMeasure", @@ -36,7 +39,7 @@ "hcatAdHocMask", "hcatMarkovBruteForce", "hcatFingerprint", "hcatCombination", "hcatCombinator3", "hcatCombinatorX", "hcatHybrid", "hcatYoloCombination", "hcatMiddleCombinator", "hcatThoroughCombinator", "hcatCombipow", "hcatPrince", - "hcatPermute" + "hcatPermute", "hcatPCFG", "hcatPrinceLing" ], "notify_enabled": false, "notify_pushover_token": "", From d673332ccd769e9f018dcafe250afdf00cae0d26 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 08:47:04 -0400 Subject: [PATCH 11/31] feat(pcfg): load pcfg config keys with sensible defaults --- hate_crack/main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hate_crack/main.py b/hate_crack/main.py index 82fff3f..8702aa5 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -450,6 +450,9 @@ ollamaNumCtx = int(config_parser.get("ollamaNumCtx", 2048)) omenTrainingList = config_parser.get("omenTrainingList", "rockyou.txt") omenMaxCandidates = int(config_parser.get("omenMaxCandidates", 1000000)) +pcfgRuleset = config_parser.get("pcfgRuleset", "DEFAULT") +pcfgMaxCandidates = int(config_parser.get("pcfgMaxCandidates", 50000000)) +pcfgPrinceLingMaxCandidates = int(config_parser.get("pcfgPrinceLingMaxCandidates", 10000000)) try: _cfg_optimized = config_parser["optimizedKernelAttacks"] From fd9732ca5722b417e444bb6f8c855a57cd11e944 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 08:48:56 -0400 Subject: [PATCH 12/31] feat(pcfg): verify pcfg_cracker presence at startup (non-fatal) --- hate_crack/main.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/hate_crack/main.py b/hate_crack/main.py index 8702aa5..91b9dd3 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -716,6 +716,16 @@ if not SKIP_INIT: except SystemExit: print("OMEN attacks will not be available.") + # Verify pcfg_cracker presence (optional, for PCFG attacks) + # pcfg_cracker is pure-Python; we just check the script files exist. + pcfg_guesser_script = os.path.join(hate_path, "pcfg_cracker", "pcfg_guesser.py") + pcfg_prince_ling_script = os.path.join(hate_path, "pcfg_cracker", "prince_ling.py") + if not os.path.isfile(pcfg_guesser_script) or not os.path.isfile(pcfg_prince_ling_script): + print("pcfg_cracker not found at " + os.path.join(hate_path, "pcfg_cracker")) + print("PCFG attacks will not be available. Run 'make' to fetch submodules.") + elif not shutil.which("python3"): + print("python3 not on PATH. PCFG attacks will not be available.") + except Exception as e: print(f"Module initialization error: {e}") if not shutil.which("hashcat") and not os.path.exists("/usr/bin/hashcat"): From 1963f80e3ea2c801fc793f9422bff8be0c319aa7 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 08:53:12 -0400 Subject: [PATCH 13/31] =?UTF-8?q?feat(pcfg):=20add=20hcatPCFG=20attack=20(?= =?UTF-8?q?mode=20A=20=E2=80=94=20pcfg=5Fguesser=20piped=20to=20hashcat)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/main.py | 47 ++++++++++++++++++++++++++++++-- tests/test_main_pcfg.py | 60 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 tests/test_main_pcfg.py diff --git a/hate_crack/main.py b/hate_crack/main.py index 91b9dd3..d493891 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -914,14 +914,17 @@ def _add_debug_mode_for_rules(cmd): # Sanitize filename for use as hashcat session name -def generate_session_id(): +def generate_session_id(hash_file=None): """Sanitize the hashfile name for use as a hashcat session name Hashcat session names can only contain alphanumeric characters, hyphens, and underscores. This function removes the file extension and replaces problematic characters. + + Args: + hash_file: Optional explicit path; falls back to the ``hcatHashFile`` global when omitted. """ # Get just the filename without path - filename = os.path.basename(hcatHashFile) + filename = os.path.basename(hash_file if hash_file is not None else hcatHashFile) # Remove extension name_without_ext = os.path.splitext(filename)[0] # Replace any non-alphanumeric chars (except - and _) with underscore @@ -2614,6 +2617,46 @@ def hcatPrince(hcatHashType, hcatHashFile): prince_proc.stdout.close() +def hcatPCFG(hcatHashType, hcatHashFile): + """Mode A: pipe pcfg_guesser.py output into hashcat in stdin mode.""" + pcfg_guesser_script = os.path.join(hate_path, "pcfg_cracker", "pcfg_guesser.py") + if not os.path.isfile(pcfg_guesser_script): + print(f"pcfg_guesser.py not found at {pcfg_guesser_script}") + return + pcfg_cmd = [ + "python3", + pcfg_guesser_script, + "--rule", + pcfgRuleset, + "--limit", + str(pcfgMaxCandidates), + ] + hashcat_cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(hcatHashFile), + "-o", + f"{hcatHashFile}.out", + ] + if _should_use_optimized_kernel("hcatPCFG"): + _insert_optimized_flag(hashcat_cmd) + hashcat_cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(hashcat_cmd) + pcfg_proc = subprocess.Popen(pcfg_cmd, stdout=subprocess.PIPE) + _run_hcat_cmd( + hashcat_cmd, + attack_name="PCFG", + hash_file=hcatHashFile, + stdin=pcfg_proc.stdout, + companion_procs=[pcfg_proc], + ) + if pcfg_proc.stdout: + pcfg_proc.stdout.close() + + def hcatPermute(hcatHashType, hcatHashFile, wordlist): global hcatProcess, hcatPermuteCount permute_path = os.path.join(hate_path, "hashcat-utils", "bin", "permute.bin") diff --git a/tests/test_main_pcfg.py b/tests/test_main_pcfg.py new file mode 100644 index 0000000..f27e999 --- /dev/null +++ b/tests/test_main_pcfg.py @@ -0,0 +1,60 @@ +"""Tests for PCFG attack subprocess construction in hate_crack.main.""" +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def hc_main(monkeypatch): + """Load hate_crack.main with SKIP_INIT and stub external bits.""" + monkeypatch.setenv("HATE_CRACK_SKIP_INIT", "1") + if "hate_crack.main" in sys.modules: + del sys.modules["hate_crack.main"] + import hate_crack.main as m + return m + + +class TestHcatPCFG: + def test_builds_expected_subprocess(self, hc_main, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + Path(hash_file).write_text("dummy") + + captured_calls = [] + + class FakeProc: + def __init__(self, *args, **kwargs): + captured_calls.append((args, kwargs)) + self.stdout = MagicMock() + self.stdout.close = MagicMock() + + with patch("hate_crack.main.subprocess.Popen", side_effect=FakeProc), \ + patch("hate_crack.main._run_hcat_cmd") as mock_run: + hc_main.hcatPCFG("0", hash_file) + + # First Popen call is the pcfg_guesser producer + producer_args, producer_kwargs = captured_calls[0] + producer_cmd = producer_args[0] + assert "python3" in producer_cmd[0] or producer_cmd[0].endswith("python3") + assert any("pcfg_guesser.py" in part for part in producer_cmd) + assert "--rule" in producer_cmd + assert producer_cmd[producer_cmd.index("--rule") + 1] == hc_main.pcfgRuleset + assert "--limit" in producer_cmd + assert producer_cmd[producer_cmd.index("--limit") + 1] == str(hc_main.pcfgMaxCandidates) + + # _run_hcat_cmd was called with attack_name='PCFG' and the hashcat command + assert mock_run.called + kwargs = mock_run.call_args.kwargs + hashcat_cmd = mock_run.call_args.args[0] + assert kwargs["attack_name"] == "PCFG" + assert kwargs["hash_file"] == hash_file + # Hashcat does NOT carry --limit (cap is producer-side) + assert "--limit" not in hashcat_cmd + # Hashcat is in stdin mode (no -a flag) + assert "-a" not in hashcat_cmd + assert "-m" in hashcat_cmd + assert hashcat_cmd[hashcat_cmd.index("-m") + 1] == "0" From ef8af059c5e3b8224645951c86207ab878ede2d0 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 08:56:32 -0400 Subject: [PATCH 14/31] fix(pcfg): revert generate_session_id signature change, patch in test instead --- hate_crack/main.py | 9 +++------ tests/test_main_pcfg.py | 3 ++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/hate_crack/main.py b/hate_crack/main.py index d493891..e0ba75c 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -914,17 +914,14 @@ def _add_debug_mode_for_rules(cmd): # Sanitize filename for use as hashcat session name -def generate_session_id(hash_file=None): +def generate_session_id(): """Sanitize the hashfile name for use as a hashcat session name Hashcat session names can only contain alphanumeric characters, hyphens, and underscores. This function removes the file extension and replaces problematic characters. - - Args: - hash_file: Optional explicit path; falls back to the ``hcatHashFile`` global when omitted. """ # Get just the filename without path - filename = os.path.basename(hash_file if hash_file is not None else hcatHashFile) + filename = os.path.basename(hcatHashFile) # Remove extension name_without_ext = os.path.splitext(filename)[0] # Replace any non-alphanumeric chars (except - and _) with underscore @@ -2637,7 +2634,7 @@ def hcatPCFG(hcatHashType, hcatHashFile): hcatHashType, hcatHashFile, "--session", - generate_session_id(hcatHashFile), + generate_session_id(), "-o", f"{hcatHashFile}.out", ] diff --git a/tests/test_main_pcfg.py b/tests/test_main_pcfg.py index f27e999..4bf0cfa 100644 --- a/tests/test_main_pcfg.py +++ b/tests/test_main_pcfg.py @@ -33,7 +33,8 @@ class TestHcatPCFG: self.stdout.close = MagicMock() with patch("hate_crack.main.subprocess.Popen", side_effect=FakeProc), \ - patch("hate_crack.main._run_hcat_cmd") as mock_run: + patch("hate_crack.main._run_hcat_cmd") as mock_run, \ + patch.object(hc_main, "generate_session_id", return_value="test_session"): hc_main.hcatPCFG("0", hash_file) # First Popen call is the pcfg_guesser producer From 54d1cfd0cad73b3722f45a58a65464ed729e4a0c Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 09:01:57 -0400 Subject: [PATCH 15/31] =?UTF-8?q?test(pcfg):=20tighten=20hcatPCFG=20test?= =?UTF-8?q?=20=E2=80=94=20patch=20globals,=20assert=20stdin=20pipe,=20drop?= =?UTF-8?q?=20unused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_main_pcfg.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/test_main_pcfg.py b/tests/test_main_pcfg.py index 4bf0cfa..c606bd6 100644 --- a/tests/test_main_pcfg.py +++ b/tests/test_main_pcfg.py @@ -1,26 +1,18 @@ """Tests for PCFG attack subprocess construction in hate_crack.main.""" -import sys from pathlib import Path from unittest.mock import MagicMock, patch import pytest -PROJECT_ROOT = Path(__file__).resolve().parents[1] - - @pytest.fixture -def hc_main(monkeypatch): - """Load hate_crack.main with SKIP_INIT and stub external bits.""" - monkeypatch.setenv("HATE_CRACK_SKIP_INIT", "1") - if "hate_crack.main" in sys.modules: - del sys.modules["hate_crack.main"] - import hate_crack.main as m - return m +def main_module(hc_module): + """Return the underlying hate_crack.main module for direct patching.""" + return hc_module._main class TestHcatPCFG: - def test_builds_expected_subprocess(self, hc_main, tmp_path): + def test_builds_expected_subprocess(self, main_module, tmp_path): hash_file = str(tmp_path / "hashes.txt") Path(hash_file).write_text("dummy") @@ -34,8 +26,11 @@ class TestHcatPCFG: with patch("hate_crack.main.subprocess.Popen", side_effect=FakeProc), \ patch("hate_crack.main._run_hcat_cmd") as mock_run, \ - patch.object(hc_main, "generate_session_id", return_value="test_session"): - hc_main.hcatPCFG("0", hash_file) + patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"): + main_module.hcatPCFG("0", hash_file) # First Popen call is the pcfg_guesser producer producer_args, producer_kwargs = captured_calls[0] @@ -43,9 +38,9 @@ class TestHcatPCFG: assert "python3" in producer_cmd[0] or producer_cmd[0].endswith("python3") assert any("pcfg_guesser.py" in part for part in producer_cmd) assert "--rule" in producer_cmd - assert producer_cmd[producer_cmd.index("--rule") + 1] == hc_main.pcfgRuleset + assert producer_cmd[producer_cmd.index("--rule") + 1] == main_module.pcfgRuleset assert "--limit" in producer_cmd - assert producer_cmd[producer_cmd.index("--limit") + 1] == str(hc_main.pcfgMaxCandidates) + assert producer_cmd[producer_cmd.index("--limit") + 1] == str(main_module.pcfgMaxCandidates) # _run_hcat_cmd was called with attack_name='PCFG' and the hashcat command assert mock_run.called @@ -59,3 +54,8 @@ class TestHcatPCFG: assert "-a" not in hashcat_cmd assert "-m" in hashcat_cmd assert hashcat_cmd[hashcat_cmd.index("-m") + 1] == "0" + + # Verify the producer is wired into hashcat's stdin via _run_hcat_cmd + assert kwargs["stdin"] is not None + assert kwargs["companion_procs"] is not None + assert len(kwargs["companion_procs"]) == 1 From b2075286c711134e215e902ad4c5e29381697a43 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 09:04:15 -0400 Subject: [PATCH 16/31] =?UTF-8?q?feat(pcfg):=20add=20hcatPrinceLing=20atta?= =?UTF-8?q?ck=20(mode=20B=20=E2=80=94=20cached=20wordlist=20via=20prince?= =?UTF-8?q?=5Fling,=20delegates=20to=20hcatPrince)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/main.py | 64 +++++++++++++++++++++++ tests/test_main_pcfg.py | 109 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) diff --git a/hate_crack/main.py b/hate_crack/main.py index e0ba75c..1a21ce9 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -2654,6 +2654,70 @@ def hcatPCFG(hcatHashType, hcatHashFile): pcfg_proc.stdout.close() +def hcatPrinceLing(hcatHashType, hcatHashFile): + """Mode B: prince_ling generates a wordlist (with cache+staleness check), + then we delegate to the existing hcatPrince attack with hcatPrinceBaseList + temporarily rebound to the cached wordlist. + """ + global hcatPrinceBaseList + pcfg_root = os.path.join(hate_path, "pcfg_cracker") + prince_ling_script = os.path.join(pcfg_root, "prince_ling.py") + ruleset_dir = os.path.join(pcfg_root, "Rules", pcfgRuleset) + if not os.path.isfile(prince_ling_script): + print(f"prince_ling.py not found at {prince_ling_script}") + return + if not os.path.isdir(ruleset_dir): + print(f"PCFG ruleset not found: {ruleset_dir}") + return + + cache_dir = hcatOptimizedWordlists if isinstance(hcatOptimizedWordlists, str) \ + else str(hcatOptimizedWordlists) + os.makedirs(cache_dir, exist_ok=True) + cache_path = os.path.join(cache_dir, f"pcfg_prince_ling_{pcfgRuleset}.txt") + tmp_path = cache_path + ".tmp" + + # Staleness check: regenerate iff ruleset dir mtime > cache mtime (strict) + needs_regen = True + if os.path.isfile(cache_path): + ruleset_mtime = os.path.getmtime(ruleset_dir) + cache_mtime = os.path.getmtime(cache_path) + if ruleset_mtime <= cache_mtime: + needs_regen = False + + if needs_regen: + print(f"[*] Generating prince_ling wordlist -> {cache_path}") + cmd = [ + "python3", + prince_ling_script, + "--rule", + pcfgRuleset, + "--output", + tmp_path, + "--size", + str(pcfgPrinceLingMaxCandidates), + ] + try: + subprocess.run(cmd, check=True) + os.replace(tmp_path, cache_path) + except (subprocess.CalledProcessError, KeyboardInterrupt) as e: + # Clean up partial tmp file + if os.path.isfile(tmp_path): + try: + os.remove(tmp_path) + except OSError: + pass + print(f"prince_ling generation failed: {e}") + return + + # Delegate to existing PRINCE attack with rebound base list + original_base = hcatPrinceBaseList + hcatPrinceBaseList = [cache_path] + try: + hcatPrince(hcatHashType, hcatHashFile) + finally: + hcatPrinceBaseList = original_base + + def hcatPermute(hcatHashType, hcatHashFile, wordlist): global hcatProcess, hcatPermuteCount permute_path = os.path.join(hate_path, "hashcat-utils", "bin", "permute.bin") diff --git a/tests/test_main_pcfg.py b/tests/test_main_pcfg.py index c606bd6..a9998e0 100644 --- a/tests/test_main_pcfg.py +++ b/tests/test_main_pcfg.py @@ -1,4 +1,5 @@ """Tests for PCFG attack subprocess construction in hate_crack.main.""" +import os from pathlib import Path from unittest.mock import MagicMock, patch @@ -59,3 +60,111 @@ class TestHcatPCFG: assert kwargs["stdin"] is not None assert kwargs["companion_procs"] is not None assert len(kwargs["companion_procs"]) == 1 + + +class TestHcatPrinceLing: + def _setup_pcfg_dirs(self, tmp_path, main_module, monkeypatch): + """Lay out fake pcfg_cracker/Rules// and optimized_wordlists/.""" + pcfg_root = tmp_path / "pcfg_cracker" + rules_dir = pcfg_root / "Rules" / "DEFAULT" + rules_dir.mkdir(parents=True) + (rules_dir / "config.txt").write_text("dummy") + # prince_ling script must "exist" for the function to proceed + (pcfg_root / "prince_ling.py").write_text("# stub") + opt_dir = tmp_path / "optimized_wordlists" + opt_dir.mkdir() + + monkeypatch.setattr(main_module, "hate_path", str(tmp_path)) + monkeypatch.setattr(main_module, "hcatOptimizedWordlists", str(opt_dir)) + return rules_dir, opt_dir + + def test_regenerates_when_cache_stale(self, main_module, tmp_path, monkeypatch): + rules_dir, opt_dir = self._setup_pcfg_dirs(tmp_path, main_module, monkeypatch) + cache = opt_dir / "pcfg_prince_ling_DEFAULT.txt" + # Cache exists but is older than ruleset + cache.write_text("stale") + old = (rules_dir.stat().st_mtime - 100) + os.utime(cache, (old, old)) + + run_calls = [] + + def fake_run(cmd, **kwargs): + run_calls.append(cmd) + # Simulate prince_ling writing the .tmp file + for i, part in enumerate(cmd): + if part == "--output": + Path(cmd[i + 1]).write_text("regenerated") + class R: + returncode = 0 + return R() + + with patch("hate_crack.main.subprocess.run", side_effect=fake_run), \ + patch("hate_crack.main.hcatPrince") as mock_prince: + main_module.hcatPrinceLing("0", str(tmp_path / "hashes.txt")) + + # prince_ling subprocess.run was invoked + assert len(run_calls) == 1 + cmd = run_calls[0] + assert any("prince_ling.py" in p for p in cmd) + assert "--rule" in cmd + assert cmd[cmd.index("--rule") + 1] == "DEFAULT" + # Uses --size, NOT --limit + assert "--size" in cmd + assert "--limit" not in cmd + # hcatPrince delegated + assert mock_prince.called + + def test_skips_regen_when_cache_fresh(self, main_module, tmp_path, monkeypatch): + rules_dir, opt_dir = self._setup_pcfg_dirs(tmp_path, main_module, monkeypatch) + cache = opt_dir / "pcfg_prince_ling_DEFAULT.txt" + cache.write_text("fresh") + # Cache is newer than ruleset + future = rules_dir.stat().st_mtime + 1000 + os.utime(cache, (future, future)) + + with patch("hate_crack.main.subprocess.run") as mock_run, \ + patch("hate_crack.main.hcatPrince"): + main_module.hcatPrinceLing("0", str(tmp_path / "hashes.txt")) + + # subprocess.run was NOT called for prince_ling + assert not mock_run.called + + def test_atomic_cache_write_cleans_tmp_on_failure(self, main_module, tmp_path, monkeypatch): + import subprocess as real_subprocess + rules_dir, opt_dir = self._setup_pcfg_dirs(tmp_path, main_module, monkeypatch) + + def boom(cmd, **kwargs): + # Touch the .tmp file then fail (simulates partial write + crash) + for i, part in enumerate(cmd): + if part == "--output": + Path(cmd[i + 1]).write_text("partial") + raise real_subprocess.CalledProcessError(1, cmd) + + with patch("hate_crack.main.subprocess.run", side_effect=boom), \ + patch("hate_crack.main.hcatPrince"): + main_module.hcatPrinceLing("0", str(tmp_path / "hashes.txt")) + + # No real cache file created; tmp file cleaned up + assert not (opt_dir / "pcfg_prince_ling_DEFAULT.txt").exists() + assert not (opt_dir / "pcfg_prince_ling_DEFAULT.txt.tmp").exists() + + def test_restores_hcatPrinceBaseList_on_exception(self, main_module, tmp_path, monkeypatch): + rules_dir, opt_dir = self._setup_pcfg_dirs(tmp_path, main_module, monkeypatch) + cache = opt_dir / "pcfg_prince_ling_DEFAULT.txt" + cache.write_text("fresh") + future = rules_dir.stat().st_mtime + 1000 + os.utime(cache, (future, future)) + + original = ["original_base.txt"] + monkeypatch.setattr(main_module, "hcatPrinceBaseList", original) + + def boom(*a, **kw): + raise RuntimeError("hcatPrince exploded") + + with patch("hate_crack.main.hcatPrince", side_effect=boom): + try: + main_module.hcatPrinceLing("0", str(tmp_path / "hashes.txt")) + except RuntimeError: + pass + + assert main_module.hcatPrinceBaseList == original From 864e67a416a9a959e9462ad95b1c6eda62466ed2 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 09:06:31 -0400 Subject: [PATCH 17/31] fix(pcfg): catch OSError on cache replace; tighten exception-restore test --- hate_crack/main.py | 2 +- tests/test_main_pcfg.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/hate_crack/main.py b/hate_crack/main.py index 1a21ce9..9842926 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -2699,7 +2699,7 @@ def hcatPrinceLing(hcatHashType, hcatHashFile): try: subprocess.run(cmd, check=True) os.replace(tmp_path, cache_path) - except (subprocess.CalledProcessError, KeyboardInterrupt) as e: + except (subprocess.CalledProcessError, KeyboardInterrupt, OSError) as e: # Clean up partial tmp file if os.path.isfile(tmp_path): try: diff --git a/tests/test_main_pcfg.py b/tests/test_main_pcfg.py index a9998e0..15eb7a3 100644 --- a/tests/test_main_pcfg.py +++ b/tests/test_main_pcfg.py @@ -161,10 +161,8 @@ class TestHcatPrinceLing: def boom(*a, **kw): raise RuntimeError("hcatPrince exploded") - with patch("hate_crack.main.hcatPrince", side_effect=boom): - try: - main_module.hcatPrinceLing("0", str(tmp_path / "hashes.txt")) - except RuntimeError: - pass + with patch("hate_crack.main.hcatPrince", side_effect=boom), \ + pytest.raises(RuntimeError): + main_module.hcatPrinceLing("0", str(tmp_path / "hashes.txt")) assert main_module.hcatPrinceBaseList == original From aff0db8540e6479eba99e02542955ae3ab500660 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 09:07:07 -0400 Subject: [PATCH 18/31] feat(pcfg): add pcfg and prince_ling dispatchers in main.py --- hate_crack/main.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/hate_crack/main.py b/hate_crack/main.py index 9842926..4e706dc 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -3933,6 +3933,14 @@ def permute_crack(): return _attacks.permute_crack(_attack_ctx()) +def pcfg_attack(): + return _attacks.pcfg_attack(_attack_ctx()) + + +def prince_ling_attack(): + return _attacks.prince_ling_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") From a512ff1fdcd356d046fa4923375b83fca651dc60 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 09:08:17 -0400 Subject: [PATCH 19/31] feat(pcfg): wire menu entries #23 (PCFG) and #24 (PRINCE-LING) in main.py --- hate_crack/main.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hate_crack/main.py b/hate_crack/main.py index 4e706dc..5b61e7e 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -4375,6 +4375,8 @@ def get_main_menu_items(): ("20", "Permutation Attack"), ("21", "Random Rules Attack"), ("22", "Combipow Passphrase Attack"), + ("23", "PCFG Attack"), + ("24", "PRINCE-LING Attack"), ("80", "Wordlist Tools"), ("81", "Rule File Tools"), ("82", "Notifications"), @@ -4419,6 +4421,8 @@ def get_main_menu_options(): "20": permute_crack, "21": generate_rules_crack, "22": combipow_crack, + "23": pcfg_attack, + "24": prince_ling_attack, "80": wordlist_tools_submenu, "81": rule_tools_submenu, "82": notifications_submenu, From 085e4196046f25dc1f2d78df0ad29ccc0709d5aa Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 09:09:39 -0400 Subject: [PATCH 20/31] feat(pcfg): add pcfg_attack and prince_ling_attack handlers in attacks.py --- hate_crack/attacks.py | 10 ++++++++++ tests/test_attacks_pcfg.py | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 tests/test_attacks_pcfg.py diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 9a26d32..5038726 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -400,6 +400,16 @@ def prince_attack(ctx: Any) -> None: ctx.hcatPrince(ctx.hcatHashType, ctx.hcatHashFile) +def pcfg_attack(ctx: Any) -> None: + _notify.prompt_notify_for_attack("PCFG") + ctx.hcatPCFG(ctx.hcatHashType, ctx.hcatHashFile) + + +def prince_ling_attack(ctx: Any) -> None: + _notify.prompt_notify_for_attack("PRINCE-LING") + ctx.hcatPrinceLing(ctx.hcatHashType, ctx.hcatHashFile) + + def yolo_combination(ctx: Any) -> None: _notify.prompt_notify_for_attack("YOLO Combination") ctx.hcatYoloCombination(ctx.hcatHashType, ctx.hcatHashFile) diff --git a/tests/test_attacks_pcfg.py b/tests/test_attacks_pcfg.py new file mode 100644 index 0000000..c489105 --- /dev/null +++ b/tests/test_attacks_pcfg.py @@ -0,0 +1,22 @@ +from unittest.mock import MagicMock + +from hate_crack.attacks import pcfg_attack, prince_ling_attack + + +def _make_ctx(hash_type: str = "1000", hash_file: str = "/tmp/hashes.txt") -> MagicMock: + ctx = MagicMock() + ctx.hcatHashType = hash_type + ctx.hcatHashFile = hash_file + return ctx + + +def test_pcfg_attack_invokes_hcatPCFG(): + ctx = _make_ctx() + pcfg_attack(ctx) + ctx.hcatPCFG.assert_called_once_with("1000", "/tmp/hashes.txt") + + +def test_prince_ling_attack_invokes_hcatPrinceLing(): + ctx = _make_ctx() + prince_ling_attack(ctx) + ctx.hcatPrinceLing.assert_called_once_with("1000", "/tmp/hashes.txt") From 1d38a6b939b8d336d3885a133117451765de2137 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 09:10:46 -0400 Subject: [PATCH 21/31] feat(pcfg): register PCFG attacks in hate_crack.py menu (dual-registration) --- hate_crack.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hate_crack.py b/hate_crack.py index 7a56b99..b0eef36 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -93,6 +93,8 @@ def get_main_menu_options(): "20": _attacks.permute_crack, "21": _attacks.generate_rules_crack, "22": _attacks.combipow_crack, + "23": _attacks.pcfg_attack, + "24": _attacks.prince_ling_attack, "80": _attacks.wordlist_tools_submenu, "81": _attacks.rule_tools_submenu, "82": notifications_submenu, From 4b7ebb53b81b508c13cb9d4b98d50444fa507cf9 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 09:11:52 -0400 Subject: [PATCH 22/31] test(pcfg): add UI menu option tests for #23 and #24 --- tests/test_ui_menu_options.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index 77f4294..82e9ab3 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -30,6 +30,8 @@ MENU_OPTION_TEST_CASES = [ ("20", CLI_MODULE._attacks, "permute_crack", "permute"), ("21", CLI_MODULE._attacks, "generate_rules_crack", "random-rules"), ("22", CLI_MODULE._attacks, "combipow_crack", "combipow"), + ("23", CLI_MODULE._attacks, "pcfg_attack", "pcfg"), + ("24", CLI_MODULE._attacks, "prince_ling_attack", "prince-ling"), ("80", CLI_MODULE._attacks, "wordlist_tools_submenu", "wordlist-tools"), ("81", CLI_MODULE._attacks, "rule_tools_submenu", "rule-tools"), ("82", CLI_MODULE, "notifications_submenu", "notifications-submenu"), From 998e680017a32987c3a1c093f8fed8a427994546 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 18:37:10 -0400 Subject: [PATCH 23/31] fix(attacks): support range syntax in rule selection (e.g. 138-141) Rule numbers entered as N-M now expand to all integers in that range before lookup, matching user expectation that 138-141 selects rules 138 through 141 sequentially. Also catches ValueError alongside IndexError when a token cannot be converted to int. Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/attacks.py | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 5038726..ab3a1c2 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -49,10 +49,10 @@ def _select_rules(ctx) -> list[str] | None: return [""] print("\nWhich rule(s) would you like to run?") - rule_entries = ["0. To run without any rules"] - rule_entries.extend([f"{i}. {file}" for i, file in enumerate(rule_files, start=1)]) - rule_entries.append("98. YOLO...run all of the rules") - rule_entries.append("99. Back to Main Menu") + rule_entries = ["0) To run without any rules"] + rule_entries.extend([f"{i}) {file}" for i, file in enumerate(rule_files, start=1)]) + rule_entries.append("98) YOLO...run all of the rules") + rule_entries.append("99) Back to Main Menu") max_rule_len = max((len(e) for e in rule_entries), default=26) print_multicolumn_list( "Available Rules", @@ -76,7 +76,21 @@ def _select_rules(ctx) -> list[str] | None: if raw_choice.strip() == "99": return None if raw_choice != "": - rule_choice = raw_choice.split(",") + tokens = raw_choice.split(",") + expanded = [] + for tok in tokens: + tok = tok.strip() + if "+" not in tok and "-" in tok: + parts = tok.split("-", 1) + try: + start, end = int(parts[0]), int(parts[1]) + if start <= end: + expanded.extend(str(i) for i in range(start, end + 1)) + continue + except ValueError: + pass + expanded.append(tok) + rule_choice = expanded if "99" in rule_choice: return None @@ -101,7 +115,7 @@ def _select_rules(ctx) -> list[str] | None: try: rule_path = os.path.join(rules_dir, rule_files[int(choice) - 1]) selected_rules.append(f"-r {rule_path}") - except IndexError: + except (IndexError, ValueError): continue return selected_rules @@ -114,7 +128,7 @@ def quick_crack(ctx: Any) -> None: wordlist_files = ctx.list_wordlist_files(default_dir) wordlist_entries = [ - f"{i}. {file}" for i, file in enumerate(wordlist_files, start=1) + f"{i}) {file}" for i, file in enumerate(wordlist_files, start=1) ] max_entry_len = max((len(e) for e in wordlist_entries), default=24) print_multicolumn_list( @@ -508,7 +522,7 @@ def _omen_pick_training_wordlist(ctx: Any): """Show wordlist picker for OMEN training. Returns path or None.""" wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) if wordlist_files: - entries = [f"{i}. {f}" for i, f in enumerate(wordlist_files, start=1)] + entries = [f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)] max_len = max((len(e) for e in entries), default=24) print_multicolumn_list( "Training Wordlists", @@ -593,8 +607,8 @@ def _markov_pick_training_source(ctx: Any): wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) entries = [] if has_cracked: - entries.append("0. Cracked passwords (current session)") - entries.extend([f"{i}. {f}" for i, f in enumerate(wordlist_files, start=1)]) + entries.append("0) Cracked passwords (current session)") + entries.extend([f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)]) if entries: max_len = max((len(e) for e in entries), default=24) print_multicolumn_list( @@ -730,7 +744,7 @@ def generate_rules_crack(ctx: Any) -> None: wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) wordlist_entries = [ - f"{i}. {file}" for i, file in enumerate(wordlist_files, start=1) + f"{i}) {file}" for i, file in enumerate(wordlist_files, start=1) ] max_entry_len = max((len(e) for e in wordlist_entries), default=24) print_multicolumn_list( From 17ca2e57b3f356b84c6c6c126ed0026e823d27a2 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 18:37:57 -0400 Subject: [PATCH 24/31] =?UTF-8?q?refactor(menu):=20close=20numbering=20gap?= =?UTF-8?q?,=20renumber=20attacks=2013-24=20=E2=86=92=2010-21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menu options jumped from 9 to 13 because items 10-12 were removed historically but remaining items kept their old numbers. Renumbers the contiguous attack block to 10-21, closing the gap. Co-Authored-By: Claude Sonnet 4.6 --- hate_crack.py | 24 ++++++++-------- hate_crack/main.py | 48 +++++++++++++++---------------- tests/test_combipow_attack.py | 6 ++-- tests/test_random_rules_attack.py | 2 +- tests/test_ui_menu_options.py | 24 ++++++++-------- 5 files changed, 52 insertions(+), 52 deletions(-) diff --git a/hate_crack.py b/hate_crack.py index b0eef36..d42fbad 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -83,18 +83,18 @@ def get_main_menu_options(): "7": _attacks.hybrid_crack, "8": _attacks.pathwell_crack, "9": _attacks.prince_attack, - "13": _attacks.bandrel_method, - "14": _attacks.loopback_attack, - "15": _attacks.ollama_attack, - "16": _attacks.omen_attack, - "17": _attacks.adhoc_mask_crack, - "18": _attacks.markov_brute_force, - "19": _attacks.ngram_attack, - "20": _attacks.permute_crack, - "21": _attacks.generate_rules_crack, - "22": _attacks.combipow_crack, - "23": _attacks.pcfg_attack, - "24": _attacks.prince_ling_attack, + "10": _attacks.bandrel_method, + "11": _attacks.loopback_attack, + "12": _attacks.ollama_attack, + "13": _attacks.omen_attack, + "14": _attacks.adhoc_mask_crack, + "15": _attacks.markov_brute_force, + "16": _attacks.ngram_attack, + "17": _attacks.permute_crack, + "18": _attacks.generate_rules_crack, + "19": _attacks.combipow_crack, + "20": _attacks.pcfg_attack, + "21": _attacks.prince_ling_attack, "80": _attacks.wordlist_tools_submenu, "81": _attacks.rule_tools_submenu, "82": notifications_submenu, diff --git a/hate_crack/main.py b/hate_crack/main.py index 5b61e7e..9f36343 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -4365,18 +4365,18 @@ def get_main_menu_items(): ("7", "Hybrid Attack"), ("8", "Pathwell Top 100 Mask Brute Force Crack"), ("9", "PRINCE Attack"), - ("13", "Bandrel Methodology"), - ("14", "Loopback Attack"), - ("15", "LLM Attack"), - ("16", "OMEN Attack"), - ("17", "Ad-hoc Mask Attack"), - ("18", "Markov Brute Force Attack"), - ("19", "N-gram Attack"), - ("20", "Permutation Attack"), - ("21", "Random Rules Attack"), - ("22", "Combipow Passphrase Attack"), - ("23", "PCFG Attack"), - ("24", "PRINCE-LING Attack"), + ("10", "Bandrel Methodology"), + ("11", "Loopback Attack"), + ("12", "LLM Attack"), + ("13", "OMEN Attack"), + ("14", "Ad-hoc Mask Attack"), + ("15", "Markov Brute Force Attack"), + ("16", "N-gram Attack"), + ("17", "Permutation Attack"), + ("18", "Random Rules Attack"), + ("19", "Combipow Passphrase Attack"), + ("20", "PCFG Attack"), + ("21", "PRINCE-LING Attack"), ("80", "Wordlist Tools"), ("81", "Rule File Tools"), ("82", "Notifications"), @@ -4411,18 +4411,18 @@ def get_main_menu_options(): "7": hybrid_crack, "8": pathwell_crack, "9": prince_attack, - "13": bandrel_method, - "14": loopback_attack, - "15": ollama_attack, - "16": omen_attack, - "17": adhoc_mask_crack, - "18": markov_brute_force, - "19": ngram_attack, - "20": permute_crack, - "21": generate_rules_crack, - "22": combipow_crack, - "23": pcfg_attack, - "24": prince_ling_attack, + "10": bandrel_method, + "11": loopback_attack, + "12": ollama_attack, + "13": omen_attack, + "14": adhoc_mask_crack, + "15": markov_brute_force, + "16": ngram_attack, + "17": permute_crack, + "18": generate_rules_crack, + "19": combipow_crack, + "20": pcfg_attack, + "21": prince_ling_attack, "80": wordlist_tools_submenu, "81": rule_tools_submenu, "82": notifications_submenu, diff --git a/tests/test_combipow_attack.py b/tests/test_combipow_attack.py index 4952ef6..7d6f0eb 100644 --- a/tests/test_combipow_attack.py +++ b/tests/test_combipow_attack.py @@ -46,16 +46,16 @@ def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"): def test_combipow_crack_in_main_menu(cli): options = cli.get_main_menu_options() - assert "22" in options + assert "19" in options def test_combipow_crack_menu_item_label(): cli = _load_cli() items = cli.get_main_menu_items() keys = [k for k, _ in items] - assert "22" in keys + assert "19" in keys labels = {k: label for k, label in items} - assert "passphrase" in labels["22"].lower() or "combipow" in labels["22"].lower() + assert "passphrase" in labels["19"].lower() or "combipow" in labels["19"].lower() # --- combipow_crack handler tests --- diff --git a/tests/test_random_rules_attack.py b/tests/test_random_rules_attack.py index c2d7761..d9c8ebc 100644 --- a/tests/test_random_rules_attack.py +++ b/tests/test_random_rules_attack.py @@ -36,7 +36,7 @@ def cli(): def test_generate_rules_crack_in_main_menu(cli): options = cli.get_main_menu_options() - assert "21" in options + assert "18" in options def test_generate_rules_crack_handler_calls_main(cli, tmp_path): diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index 82e9ab3..ab938d6 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -20,18 +20,18 @@ MENU_OPTION_TEST_CASES = [ ("7", CLI_MODULE._attacks, "hybrid_crack", "hybrid"), ("8", CLI_MODULE._attacks, "pathwell_crack", "pathwell"), ("9", CLI_MODULE._attacks, "prince_attack", "prince"), - ("13", CLI_MODULE._attacks, "bandrel_method", "bandrel"), - ("14", CLI_MODULE._attacks, "loopback_attack", "loopback"), - ("15", CLI_MODULE._attacks, "ollama_attack", "ollama"), - ("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"), - ("19", CLI_MODULE._attacks, "ngram_attack", "ngram"), - ("20", CLI_MODULE._attacks, "permute_crack", "permute"), - ("21", CLI_MODULE._attacks, "generate_rules_crack", "random-rules"), - ("22", CLI_MODULE._attacks, "combipow_crack", "combipow"), - ("23", CLI_MODULE._attacks, "pcfg_attack", "pcfg"), - ("24", CLI_MODULE._attacks, "prince_ling_attack", "prince-ling"), + ("10", CLI_MODULE._attacks, "bandrel_method", "bandrel"), + ("11", CLI_MODULE._attacks, "loopback_attack", "loopback"), + ("12", CLI_MODULE._attacks, "ollama_attack", "ollama"), + ("13", CLI_MODULE._attacks, "omen_attack", "omen"), + ("14", CLI_MODULE._attacks, "adhoc_mask_crack", "adhoc-mask"), + ("15", CLI_MODULE._attacks, "markov_brute_force", "markov-brute"), + ("16", CLI_MODULE._attacks, "ngram_attack", "ngram"), + ("17", CLI_MODULE._attacks, "permute_crack", "permute"), + ("18", CLI_MODULE._attacks, "generate_rules_crack", "random-rules"), + ("19", CLI_MODULE._attacks, "combipow_crack", "combipow"), + ("20", CLI_MODULE._attacks, "pcfg_attack", "pcfg"), + ("21", CLI_MODULE._attacks, "prince_ling_attack", "prince-ling"), ("80", CLI_MODULE._attacks, "wordlist_tools_submenu", "wordlist-tools"), ("81", CLI_MODULE._attacks, "rule_tools_submenu", "rule-tools"), ("82", CLI_MODULE, "notifications_submenu", "notifications-submenu"), From 1282f12e2aeb5852db569a1b1c99d7b747729b04 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 18:38:03 -0400 Subject: [PATCH 25/31] fix(menu): plain numbered menu by default with aligned key columns - Switch default from simple-term-menu (arrow-key) to plain numbered input so all keys including 10+ can be selected by typing a number; arrow-key mode is now opt-in via HATE_CRACK_ARROW_MENU=1 - Right-align key numbers within brackets so single- and multi-digit items line up: [ 1] through [99] - Use [key] format consistently in both plain and arrow-key modes Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/menu.py | 23 ++++++++++------------- tests/test_menu.py | 18 ++++++++++-------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/hate_crack/menu.py b/hate_crack/menu.py index 2f5504c..f99b81d 100644 --- a/hate_crack/menu.py +++ b/hate_crack/menu.py @@ -1,10 +1,10 @@ """Reusable interactive menu with optional arrow-key navigation. -When ``simple-term-menu`` is installed AND stdout is a TTY, renders an -arrow-key navigable menu. Otherwise falls back to classic numbered -``print()`` + ``input()`` selection. +Default: classic numbered ``print()`` + ``input()`` selection (full number +entry for all keys). -Set ``HATE_CRACK_PLAIN_MENU=1`` to force the plain numbered menu. +Set ``HATE_CRACK_ARROW_MENU=1`` to enable arrow-key navigation via +``simple-term-menu`` (single-digit shortcut keys only; 10+ require arrows). """ from __future__ import annotations @@ -21,7 +21,7 @@ except ImportError: def _use_arrow_menu() -> bool: - if os.environ.get("HATE_CRACK_PLAIN_MENU", "") == "1": + if os.environ.get("HATE_CRACK_ARROW_MENU", "") != "1": return False if not _HAS_TERM_MENU: return False @@ -34,15 +34,11 @@ def _arrow_menu( items: list[tuple[str, str]], title: str | None, ) -> str | None: - menu_entries = [f"[{key}] {label}" for key, label in items] + w = max(len(key) for key, _ in items) + menu_entries = [f"[{key:>{w}}] {label}" for key, label in items] shortcuts = [key for key, _ in items] - # Build shortcut_key_highlight_style so pressing a number jumps there - menu = TerminalMenu( - menu_entries, - title=title, - shortcut_key_highlight_style=("standout",), - ) + menu = TerminalMenu(menu_entries, title=title) idx = menu.show() if idx is None: return None @@ -53,8 +49,9 @@ def _numbered_menu( items: list[tuple[str, str]], prompt: str, ) -> str | None: + w = max(len(key) for key, _ in items) for key, label in items: - print(f"\t({key}) {label}") + print(f"\t[{key:>{w}}] {label}") choice = input(prompt).strip() if not choice: return None diff --git a/tests/test_menu.py b/tests/test_menu.py index 31ebc6c..3ef46bc 100644 --- a/tests/test_menu.py +++ b/tests/test_menu.py @@ -19,29 +19,30 @@ class TestUseArrowMenu: import hate_crack.menu as mod monkeypatch.setattr(mod, "_HAS_TERM_MENU", False) - monkeypatch.delenv("HATE_CRACK_PLAIN_MENU", raising=False) + monkeypatch.setenv("HATE_CRACK_ARROW_MENU", "1") assert _use_arrow_menu() is False def test_falls_back_on_non_tty(self, monkeypatch): import hate_crack.menu as mod monkeypatch.setattr(mod, "_HAS_TERM_MENU", True) - monkeypatch.delenv("HATE_CRACK_PLAIN_MENU", raising=False) + monkeypatch.setenv("HATE_CRACK_ARROW_MENU", "1") monkeypatch.setattr("sys.stdout.isatty", lambda: False) assert _use_arrow_menu() is False - def test_falls_back_with_env_var(self, monkeypatch): + def test_falls_back_without_env_var(self, monkeypatch): import hate_crack.menu as mod monkeypatch.setattr(mod, "_HAS_TERM_MENU", True) - monkeypatch.setenv("HATE_CRACK_PLAIN_MENU", "1") + monkeypatch.delenv("HATE_CRACK_ARROW_MENU", raising=False) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) assert _use_arrow_menu() is False def test_enabled_when_all_conditions_met(self, monkeypatch): import hate_crack.menu as mod monkeypatch.setattr(mod, "_HAS_TERM_MENU", True) - monkeypatch.delenv("HATE_CRACK_PLAIN_MENU", raising=False) + monkeypatch.setenv("HATE_CRACK_ARROW_MENU", "1") monkeypatch.setattr("sys.stdout.isatty", lambda: True) assert _use_arrow_menu() is True @@ -56,8 +57,9 @@ class TestNumberedMenu: monkeypatch.setattr("builtins.input", lambda _: "1") _numbered_menu(SAMPLE_ITEMS, "\nSelect: ") captured = capsys.readouterr().out + w = max(len(key) for key, _ in SAMPLE_ITEMS) for key, label in SAMPLE_ITEMS: - assert f"({key}) {label}" in captured + assert f"[{key:>{w}}] {label}" in captured def test_returns_none_on_empty_input(self, monkeypatch): monkeypatch.setattr("builtins.input", lambda _: "") @@ -94,7 +96,7 @@ class TestInteractiveMenu: import hate_crack.menu as mod monkeypatch.setattr(mod, "_HAS_TERM_MENU", False) - monkeypatch.delenv("HATE_CRACK_PLAIN_MENU", raising=False) + monkeypatch.delenv("HATE_CRACK_ARROW_MENU", raising=False) monkeypatch.setattr("builtins.input", lambda _: "99") result = interactive_menu(SAMPLE_ITEMS) assert result == "99" @@ -103,7 +105,7 @@ class TestInteractiveMenu: import hate_crack.menu as mod monkeypatch.setattr(mod, "_HAS_TERM_MENU", True) - monkeypatch.delenv("HATE_CRACK_PLAIN_MENU", raising=False) + monkeypatch.setenv("HATE_CRACK_ARROW_MENU", "1") monkeypatch.setattr("sys.stdout.isatty", lambda: True) mock_menu_instance = MagicMock() mock_menu_instance.show.return_value = 0 From efd525a4a70fcd03bb5a910129e30a7cf02591a5 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 21:13:10 -0400 Subject: [PATCH 26/31] feat(wordlist-tools): add Optimize Wordlists submenu option (8) Adds wordlist_optimize worker to main.py that consolidates multiple wordlists into per-length deduplicated files using splitlen.bin and rli2.bin. Wires handler in attacks.py and registers it as option 8 in the Wordlist Tools submenu. Adds 16 tests covering happy path, empty input, missing files, empty outdir, failure path, and submenu dispatch. Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/attacks.py | 30 ++++++++++++++ hate_crack/main.py | 37 +++++++++++++++++ tests/test_wordlist_tools.py | 80 ++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index ab3a1c2..2394bec 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -1192,6 +1192,33 @@ def wordlist_shard(ctx: Any) -> None: print("[!] Shard failed.") +def wordlist_optimize(ctx: Any) -> None: + """Prompt for input wordlists and output directory, then optimize.""" + raw = ctx.select_file_with_autocomplete( + "\n[*] Enter input wordlist paths", + allow_multiple=True, + base_dir=ctx.hcatWordlists, + ).strip() + inputs = [p.strip() for p in raw.split(",") if p.strip()] + if not inputs: + print("[!] No input wordlists provided.") + return + missing = [p for p in inputs if not os.path.isfile(p)] + if missing: + print("[!] Files not found:") + for p in missing: + print(f" {p}") + return + outdir = ctx.select_file_with_autocomplete("[*] Enter output directory path").strip() + if not outdir: + print("[!] Output directory cannot be empty.") + return + if ctx.wordlist_optimize(inputs, outdir): + print(f"\n[*] Optimized wordlists written to: {outdir}") + else: + print("[!] Optimization failed.") + + def wordlist_tools_submenu(ctx: Any) -> None: """Display the Wordlist Tools submenu and dispatch to the selected handler.""" items = [ @@ -1202,6 +1229,7 @@ def wordlist_tools_submenu(ctx: Any) -> None: ("5", "Split by Length"), ("6", "Subtract Wordlist"), ("7", "Shard Wordlist"), + ("8", "Optimize Wordlists"), ("99", "Back to Main Menu"), ] while True: @@ -1222,3 +1250,5 @@ def wordlist_tools_submenu(ctx: Any) -> None: wordlist_subtract_words(ctx) elif choice == "7": wordlist_shard(ctx) + elif choice == "8": + wordlist_optimize(ctx) diff --git a/hate_crack/main.py b/hate_crack/main.py index 9f36343..8419961 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -4011,6 +4011,43 @@ def wordlist_gate(infile: str, outfile: str, mod: int, offset: int) -> bool: return result.returncode == 0 +def wordlist_optimize(input_wordlists: list[str], outdir: str) -> bool: + """Consolidate wordlists into per-length deduplicated files in outdir.""" + import tempfile + os.makedirs(outdir, exist_ok=True) + for wl in input_wordlists: + if not os.path.isfile(wl): + print(f"[!] Skipping missing wordlist: {wl}") + continue + if not os.listdir(outdir): + if not wordlist_splitlen(wl, outdir): + return False + continue + with tempfile.TemporaryDirectory(prefix="hc_optimize_") as tmp: + if not wordlist_splitlen(wl, tmp): + return False + for fname in os.listdir(tmp): + src = os.path.join(tmp, fname) + dst = os.path.join(outdir, fname) + if not os.path.isfile(dst): + shutil.copyfile(src, dst) + continue + with tempfile.NamedTemporaryFile( + delete=False, prefix="hc_optimize_", suffix=".out" + ) as out_fh: + out_path = out_fh.name + try: + if not wordlist_subtract_single(src, dst, out_path): + return False + if os.path.getsize(out_path) > 0: + with open(dst, "ab") as df, open(out_path, "rb") as sf: + df.write(sf.read()) + finally: + if os.path.isfile(out_path): + os.remove(out_path) + return True + + def wordlist_tools_submenu(): return _attacks.wordlist_tools_submenu(_attack_ctx()) diff --git a/tests/test_wordlist_tools.py b/tests/test_wordlist_tools.py index 36cd5b8..e6a4114 100644 --- a/tests/test_wordlist_tools.py +++ b/tests/test_wordlist_tools.py @@ -9,6 +9,7 @@ from hate_crack.attacks import ( wordlist_filter_charclass_exclude, wordlist_filter_charclass_include, wordlist_filter_length, + wordlist_optimize, wordlist_shard, wordlist_split_by_length, wordlist_subtract_words, @@ -26,6 +27,7 @@ def _make_ctx(): ctx.wordlist_subtract.return_value = True ctx.wordlist_subtract_single.return_value = True ctx.wordlist_gate.return_value = True + ctx.wordlist_optimize.return_value = True return ctx @@ -297,6 +299,13 @@ class TestWordlistToolsSubmenu: wordlist_tools_submenu(ctx) mock_fn.assert_called_once_with(ctx) + def test_submenu_dispatches_to_optimize(self): + ctx = _make_ctx() + with patch("hate_crack.attacks.wordlist_optimize") as mock_fn, \ + patch("hate_crack.attacks.interactive_menu", side_effect=["8", "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"): @@ -306,3 +315,74 @@ class TestWordlistToolsSubmenu: ctx = _make_ctx() with patch("hate_crack.attacks.interactive_menu", return_value=None): wordlist_tools_submenu(ctx) + + +class TestWordlistOptimize: + def test_happy_path(self, tmp_path, capsys): + ctx = _make_ctx() + wl_a = tmp_path / "a.txt" + wl_a.write_text("word1\n") + wl_b = tmp_path / "b.txt" + wl_b.write_text("word2\n") + outdir = str(tmp_path / "out") + ctx.select_file_with_autocomplete.side_effect = [ + f"{wl_a},{wl_b}", + outdir, + ] + with patch("hate_crack.attacks.os.path.isfile", return_value=True): + wordlist_optimize(ctx) + ctx.wordlist_optimize.assert_called_once_with( + [str(wl_a), str(wl_b)], outdir + ) + out = capsys.readouterr().out + assert outdir in out + + def test_empty_input_rejection(self, capsys): + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = "," + wordlist_optimize(ctx) + out = capsys.readouterr().out + assert "No input wordlists provided" in out + ctx.wordlist_optimize.assert_not_called() + + def test_blank_input_rejection(self, capsys): + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = "" + wordlist_optimize(ctx) + out = capsys.readouterr().out + assert "No input wordlists provided" in out + ctx.wordlist_optimize.assert_not_called() + + def test_missing_file_rejection(self, tmp_path, capsys): + ctx = _make_ctx() + existing = tmp_path / "a.txt" + existing.write_text("word\n") + ctx.select_file_with_autocomplete.return_value = f"{existing},/nonexistent/missing.txt" + with patch("hate_crack.attacks.os.path.isfile", side_effect=lambda p: p == str(existing)): + wordlist_optimize(ctx) + out = capsys.readouterr().out + assert "Files not found" in out + ctx.wordlist_optimize.assert_not_called() + + def test_empty_outdir_rejection(self, tmp_path, capsys): + ctx = _make_ctx() + wl = tmp_path / "a.txt" + wl.write_text("word\n") + ctx.select_file_with_autocomplete.side_effect = [str(wl), ""] + with patch("hate_crack.attacks.os.path.isfile", return_value=True): + wordlist_optimize(ctx) + out = capsys.readouterr().out + assert "Output directory cannot be empty" in out + ctx.wordlist_optimize.assert_not_called() + + def test_failure_path(self, tmp_path, capsys): + ctx = _make_ctx() + ctx.wordlist_optimize.return_value = False + wl = tmp_path / "a.txt" + wl.write_text("word\n") + outdir = str(tmp_path / "out") + ctx.select_file_with_autocomplete.side_effect = [str(wl), outdir] + with patch("hate_crack.attacks.os.path.isfile", return_value=True): + wordlist_optimize(ctx) + out = capsys.readouterr().out + assert "Optimization failed" in out From 81015787ed58990b2e0fe3705db2ee1ece472eca Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 21:20:53 -0400 Subject: [PATCH 27/31] fix(wordlist-optimize): remove allow_multiple crash, redundant import, add worker tests - Remove `allow_multiple=True` from wordlist_optimize handler to avoid AttributeError when the return value is a list rather than a str - Remove redundant `import tempfile` inside wordlist_optimize worker body (tempfile is already imported at module level on line 29) - Add TestWordlistOptimizeWorker with 6 tests covering: fast-path (empty outdir), merge-path (subtract+append), new-length copy, splitlen failure, subtract failure, and missing-input skip Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/attacks.py | 1 - hate_crack/main.py | 1 - tests/test_wordlist_tools.py | 171 ++++++++++++++++++++++++++++++++++- 3 files changed, 170 insertions(+), 3 deletions(-) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 2394bec..3f3d415 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -1196,7 +1196,6 @@ def wordlist_optimize(ctx: Any) -> None: """Prompt for input wordlists and output directory, then optimize.""" raw = ctx.select_file_with_autocomplete( "\n[*] Enter input wordlist paths", - allow_multiple=True, base_dir=ctx.hcatWordlists, ).strip() inputs = [p.strip() for p in raw.split(",") if p.strip()] diff --git a/hate_crack/main.py b/hate_crack/main.py index 8419961..cb2c3af 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -4013,7 +4013,6 @@ def wordlist_gate(infile: str, outfile: str, mod: int, offset: int) -> bool: def wordlist_optimize(input_wordlists: list[str], outdir: str) -> bool: """Consolidate wordlists into per-length deduplicated files in outdir.""" - import tempfile os.makedirs(outdir, exist_ok=True) for wl in input_wordlists: if not os.path.isfile(wl): diff --git a/tests/test_wordlist_tools.py b/tests/test_wordlist_tools.py index e6a4114..30e12fe 100644 --- a/tests/test_wordlist_tools.py +++ b/tests/test_wordlist_tools.py @@ -1,6 +1,7 @@ import os +import shutil from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch import pytest @@ -386,3 +387,171 @@ class TestWordlistOptimize: wordlist_optimize(ctx) out = capsys.readouterr().out assert "Optimization failed" in out + + +class TestWordlistOptimizeWorker: + """Tests for the wordlist_optimize worker function in hate_crack.main. + + All binary-calling helpers (wordlist_splitlen, wordlist_subtract_single) + are mocked so no real binaries are required. + """ + + def _get_worker(self): + import importlib + import sys + # Import the module fresh; SKIP_INIT is already active via conftest. + mod = sys.modules.get("hate_crack.main") + if mod is None: + mod = importlib.import_module("hate_crack.main") + return mod.wordlist_optimize + + # ------------------------------------------------------------------ + # (a) fast-path: empty outdir → wordlist_splitlen called directly + # ------------------------------------------------------------------ + def test_fast_path_empty_outdir(self, tmp_path): + worker = self._get_worker() + wl = tmp_path / "words.txt" + wl.write_text("word\n") + outdir = tmp_path / "out" + outdir.mkdir() + + with patch("hate_crack.main.wordlist_splitlen", return_value=True) as mock_split, \ + patch("hate_crack.main.wordlist_subtract_single") as mock_sub: + result = worker([str(wl)], str(outdir)) + + assert result is True + mock_split.assert_called_once_with(str(wl), str(outdir)) + mock_sub.assert_not_called() + + # ------------------------------------------------------------------ + # (b) merge-path: existing per-length file → subtract_single + append + # ------------------------------------------------------------------ + def test_merge_path_existing_length_file(self, tmp_path): + worker = self._get_worker() + wl_a = tmp_path / "a.txt" + wl_a.write_text("word1\n") + wl_b = tmp_path / "b.txt" + wl_b.write_text("word2\n") + outdir = tmp_path / "out" + outdir.mkdir() + + # After the first wordlist, outdir contains "len5.txt" + len_file = outdir / "len5.txt" + len_file.write_text("word1\n") + + # The second call goes through the merge path for the tmp subdir. + # wordlist_splitlen for wl_b produces "len5.txt" in a temp dir. + # wordlist_subtract_single produces a non-empty output → append. + new_words = b"word2\n" + + def fake_splitlen(infile: str, outdir_arg: str) -> bool: + # Write a len5.txt into wherever we are called to write to + (Path(outdir_arg) / "len5.txt").write_bytes(b"word2\n") + return True + + def fake_subtract(src: str, dst: str, out: str) -> bool: + # Simulate: the diff is "word2\n" + with open(out, "wb") as f: + f.write(new_words) + return True + + with patch("hate_crack.main.wordlist_splitlen", side_effect=fake_splitlen), \ + patch("hate_crack.main.wordlist_subtract_single", side_effect=fake_subtract): + # outdir is already non-empty (len_file exists), so wl_b goes to merge path + result = worker([str(wl_b)], str(outdir)) + + assert result is True + # len5.txt should now contain the appended new words + contents = len_file.read_bytes() + assert b"word2\n" in contents + + # ------------------------------------------------------------------ + # (c) new length file in subsequent wordlist is just copied + # ------------------------------------------------------------------ + def test_new_length_file_is_copied(self, tmp_path): + worker = self._get_worker() + wl_b = tmp_path / "b.txt" + wl_b.write_text("verylongword\n") + outdir = tmp_path / "out" + outdir.mkdir() + + # Seed outdir with one length file (len5) so it is non-empty + (outdir / "len5.txt").write_text("hello\n") + + # The second wordlist produces only "len12.txt" (no clash) + def fake_splitlen(infile: str, outdir_arg: str) -> bool: + (Path(outdir_arg) / "len12.txt").write_bytes(b"verylongword\n") + return True + + with patch("hate_crack.main.wordlist_splitlen", side_effect=fake_splitlen), \ + patch("hate_crack.main.wordlist_subtract_single") as mock_sub: + result = worker([str(wl_b)], str(outdir)) + + assert result is True + assert (outdir / "len12.txt").exists() + assert (outdir / "len12.txt").read_bytes() == b"verylongword\n" + mock_sub.assert_not_called() + + # ------------------------------------------------------------------ + # (d) wordlist_splitlen failure returns False + # ------------------------------------------------------------------ + def test_splitlen_failure_returns_false(self, tmp_path): + worker = self._get_worker() + wl = tmp_path / "words.txt" + wl.write_text("word\n") + outdir = tmp_path / "out" + outdir.mkdir() + + with patch("hate_crack.main.wordlist_splitlen", return_value=False): + result = worker([str(wl)], str(outdir)) + + assert result is False + + # ------------------------------------------------------------------ + # (e) wordlist_subtract_single failure returns False + # ------------------------------------------------------------------ + def test_subtract_failure_returns_false(self, tmp_path): + worker = self._get_worker() + wl_b = tmp_path / "b.txt" + wl_b.write_text("word2\n") + outdir = tmp_path / "out" + outdir.mkdir() + + # Seed outdir so it's non-empty and has a clashing length file + (outdir / "len5.txt").write_text("word1\n") + + def fake_splitlen(infile: str, outdir_arg: str) -> bool: + (Path(outdir_arg) / "len5.txt").write_bytes(b"word2\n") + return True + + with patch("hate_crack.main.wordlist_splitlen", side_effect=fake_splitlen), \ + patch("hate_crack.main.wordlist_subtract_single", return_value=False): + result = worker([str(wl_b)], str(outdir)) + + assert result is False + + # ------------------------------------------------------------------ + # (f) missing input file is skipped and processing continues + # ------------------------------------------------------------------ + def test_missing_input_skipped_processing_continues(self, tmp_path, capsys): + worker = self._get_worker() + good_wl = tmp_path / "good.txt" + good_wl.write_text("word\n") + missing = str(tmp_path / "nonexistent.txt") + outdir = tmp_path / "out" + outdir.mkdir() + + call_count = {"n": 0} + + def fake_splitlen(infile: str, outdir_arg: str) -> bool: + call_count["n"] += 1 + return True + + with patch("hate_crack.main.wordlist_splitlen", side_effect=fake_splitlen): + result = worker([missing, str(good_wl)], str(outdir)) + + assert result is True + # Only the good wordlist should have been processed + assert call_count["n"] == 1 + out = capsys.readouterr().out + assert "Skipping" in out From 31c476a7a2c96122d3095aae0d04d051ae61d0bf Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 21:26:47 -0400 Subject: [PATCH 28/31] fix(wordlist-optimize): use rli.bin (hash-set) instead of rli2.bin (sorted merge-join) wordlist_optimize worker called wordlist_subtract_single (rli2.bin), which requires both files to be lexicographically sorted. splitlen.bin preserves insertion order, so rli2.bin silently missed duplicates. Switch to wordlist_subtract (rli.bin), which loads the remove-list into a hash set and is order-independent. Also add "(comma-separated)" to the input paths prompt. Update worker tests to patch wordlist_subtract instead of wordlist_subtract_single. Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/attacks.py | 2 +- hate_crack/main.py | 2 +- tests/test_wordlist_tools.py | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 3f3d415..11919c5 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -1195,7 +1195,7 @@ def wordlist_shard(ctx: Any) -> None: def wordlist_optimize(ctx: Any) -> None: """Prompt for input wordlists and output directory, then optimize.""" raw = ctx.select_file_with_autocomplete( - "\n[*] Enter input wordlist paths", + "\n[*] Enter input wordlist paths (comma-separated)", base_dir=ctx.hcatWordlists, ).strip() inputs = [p.strip() for p in raw.split(",") if p.strip()] diff --git a/hate_crack/main.py b/hate_crack/main.py index cb2c3af..a4d8681 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -4036,7 +4036,7 @@ def wordlist_optimize(input_wordlists: list[str], outdir: str) -> bool: ) as out_fh: out_path = out_fh.name try: - if not wordlist_subtract_single(src, dst, out_path): + if not wordlist_subtract(src, out_path, dst): return False if os.path.getsize(out_path) > 0: with open(dst, "ab") as df, open(out_path, "rb") as sf: diff --git a/tests/test_wordlist_tools.py b/tests/test_wordlist_tools.py index 30e12fe..52710f7 100644 --- a/tests/test_wordlist_tools.py +++ b/tests/test_wordlist_tools.py @@ -416,7 +416,7 @@ class TestWordlistOptimizeWorker: outdir.mkdir() with patch("hate_crack.main.wordlist_splitlen", return_value=True) as mock_split, \ - patch("hate_crack.main.wordlist_subtract_single") as mock_sub: + patch("hate_crack.main.wordlist_subtract") as mock_sub: result = worker([str(wl)], str(outdir)) assert result is True @@ -424,7 +424,7 @@ class TestWordlistOptimizeWorker: mock_sub.assert_not_called() # ------------------------------------------------------------------ - # (b) merge-path: existing per-length file → subtract_single + append + # (b) merge-path: existing per-length file → wordlist_subtract + append # ------------------------------------------------------------------ def test_merge_path_existing_length_file(self, tmp_path): worker = self._get_worker() @@ -441,7 +441,7 @@ class TestWordlistOptimizeWorker: # The second call goes through the merge path for the tmp subdir. # wordlist_splitlen for wl_b produces "len5.txt" in a temp dir. - # wordlist_subtract_single produces a non-empty output → append. + # wordlist_subtract produces a non-empty output → append. new_words = b"word2\n" def fake_splitlen(infile: str, outdir_arg: str) -> bool: @@ -449,14 +449,14 @@ class TestWordlistOptimizeWorker: (Path(outdir_arg) / "len5.txt").write_bytes(b"word2\n") return True - def fake_subtract(src: str, dst: str, out: str) -> bool: - # Simulate: the diff is "word2\n" + def fake_subtract(src: str, out: str, *remove_files: str) -> bool: + # Simulate: the diff is "word2\n" — write to outfile (second arg) with open(out, "wb") as f: f.write(new_words) return True with patch("hate_crack.main.wordlist_splitlen", side_effect=fake_splitlen), \ - patch("hate_crack.main.wordlist_subtract_single", side_effect=fake_subtract): + patch("hate_crack.main.wordlist_subtract", side_effect=fake_subtract): # outdir is already non-empty (len_file exists), so wl_b goes to merge path result = worker([str(wl_b)], str(outdir)) @@ -484,7 +484,7 @@ class TestWordlistOptimizeWorker: return True with patch("hate_crack.main.wordlist_splitlen", side_effect=fake_splitlen), \ - patch("hate_crack.main.wordlist_subtract_single") as mock_sub: + patch("hate_crack.main.wordlist_subtract") as mock_sub: result = worker([str(wl_b)], str(outdir)) assert result is True @@ -508,7 +508,7 @@ class TestWordlistOptimizeWorker: assert result is False # ------------------------------------------------------------------ - # (e) wordlist_subtract_single failure returns False + # (e) wordlist_subtract failure returns False # ------------------------------------------------------------------ def test_subtract_failure_returns_false(self, tmp_path): worker = self._get_worker() @@ -525,7 +525,7 @@ class TestWordlistOptimizeWorker: return True with patch("hate_crack.main.wordlist_splitlen", side_effect=fake_splitlen), \ - patch("hate_crack.main.wordlist_subtract_single", return_value=False): + patch("hate_crack.main.wordlist_subtract", return_value=False): result = worker([str(wl_b)], str(outdir)) assert result is False From 811606e796526f359b9574239460d85fdd300502 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Tue, 5 May 2026 15:42:56 -0400 Subject: [PATCH 29/31] feat(wordlist-optimize): accept directories as input, inline tab-completion display, add Wordlist/Rule Tools to main menu Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/attacks.py | 26 +++++++++++++----- hate_crack/main.py | 19 +++++++++++--- tests/test_wordlist_tools.py | 51 ++++++++++++++++++++++++++++++++---- 3 files changed, 80 insertions(+), 16 deletions(-) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 11919c5..9112363 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -1195,17 +1195,29 @@ def wordlist_shard(ctx: Any) -> None: def wordlist_optimize(ctx: Any) -> None: """Prompt for input wordlists and output directory, then optimize.""" raw = ctx.select_file_with_autocomplete( - "\n[*] Enter input wordlist paths (comma-separated)", + "\n[*] Enter input wordlist paths (comma-separated files or directories)", base_dir=ctx.hcatWordlists, ).strip() - inputs = [p.strip() for p in raw.split(",") if p.strip()] - if not inputs: + raw_entries = [p.strip() for p in raw.split(",") if p.strip()] + if not raw_entries: print("[!] No input wordlists provided.") return - missing = [p for p in inputs if not os.path.isfile(p)] - if missing: - print("[!] Files not found:") - for p in missing: + inputs: list[str] = [] + not_found: list[str] = [] + for entry in raw_entries: + if os.path.isfile(entry): + inputs.append(entry) + elif os.path.isdir(entry): + files = [os.path.join(entry, f) for f in ctx.list_wordlist_files(entry)] + if not files: + print(f"[!] No wordlist files found in: {entry}") + return + inputs.extend(files) + else: + not_found.append(entry) + if not_found: + print("[!] Not found (not a file or directory):") + for p in not_found: print(f" {p}") return outdir = ctx.select_file_with_autocomplete("[*] Enter output directory path").strip() diff --git a/hate_crack/main.py b/hate_crack/main.py index a4d8681..e06e7ca 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -1079,12 +1079,17 @@ def select_file_with_autocomplete( except IndexError: return None + def display_matches(substitution, matches, longest_match_length): + print() + for match in matches: + print(f" {match}") + readline.redisplay() + # Configure readline for tab completion readline.set_completer_delims(" \t\n;") - # Disable the "Display all X possibilities?" prompt try: - readline.parse_and_bind("set completion-query-items -1") - except Exception: + readline.set_completion_display_matches_hook(display_matches) + except AttributeError: pass try: readline.parse_and_bind("tab: complete") @@ -4892,7 +4897,9 @@ def main(): ("2", "Download wordlists from Weakpass"), ("3", "Download wordlists from Hashmob.net"), ("4", "Download rules from Hashmob.net"), - ("5", "Exit"), + ("5", "Wordlist Tools"), + ("6", "Rule File Tools"), + ("7", "Exit"), ] menu_loop = True while menu_loop: @@ -4930,6 +4937,10 @@ def main(): sys.exit(0) # Otherwise continue the menu loop elif choice == "5": + wordlist_tools_submenu() + elif choice == "6": + rule_tools_submenu() + elif choice == "7": sys.exit(0) else: if ( diff --git a/tests/test_wordlist_tools.py b/tests/test_wordlist_tools.py index 52710f7..86a7508 100644 --- a/tests/test_wordlist_tools.py +++ b/tests/test_wordlist_tools.py @@ -330,7 +330,10 @@ class TestWordlistOptimize: f"{wl_a},{wl_b}", outdir, ] - with patch("hate_crack.attacks.os.path.isfile", return_value=True): + with ( + patch("hate_crack.attacks.os.path.isfile", return_value=True), + patch("hate_crack.attacks.os.path.isdir", return_value=False), + ): wordlist_optimize(ctx) ctx.wordlist_optimize.assert_called_once_with( [str(wl_a), str(wl_b)], outdir @@ -338,6 +341,35 @@ class TestWordlistOptimize: out = capsys.readouterr().out assert outdir in out + def test_directory_expansion(self, tmp_path, capsys): + ctx = _make_ctx() + wl_dir = str(tmp_path / "wls") + outdir = str(tmp_path / "out") + ctx.select_file_with_autocomplete.side_effect = [wl_dir, outdir] + ctx.list_wordlist_files.return_value = ["a.txt", "b.txt"] + with ( + patch("hate_crack.attacks.os.path.isfile", return_value=False), + patch("hate_crack.attacks.os.path.isdir", return_value=True), + ): + wordlist_optimize(ctx) + ctx.wordlist_optimize.assert_called_once_with( + [os.path.join(wl_dir, "a.txt"), os.path.join(wl_dir, "b.txt")], outdir + ) + + def test_empty_directory_rejection(self, tmp_path, capsys): + ctx = _make_ctx() + wl_dir = str(tmp_path / "wls") + ctx.select_file_with_autocomplete.return_value = wl_dir + ctx.list_wordlist_files.return_value = [] + with ( + patch("hate_crack.attacks.os.path.isfile", return_value=False), + patch("hate_crack.attacks.os.path.isdir", return_value=True), + ): + wordlist_optimize(ctx) + out = capsys.readouterr().out + assert "No wordlist files found" in out + ctx.wordlist_optimize.assert_not_called() + def test_empty_input_rejection(self, capsys): ctx = _make_ctx() ctx.select_file_with_autocomplete.return_value = "," @@ -359,10 +391,13 @@ class TestWordlistOptimize: existing = tmp_path / "a.txt" existing.write_text("word\n") ctx.select_file_with_autocomplete.return_value = f"{existing},/nonexistent/missing.txt" - with patch("hate_crack.attacks.os.path.isfile", side_effect=lambda p: p == str(existing)): + with ( + patch("hate_crack.attacks.os.path.isfile", side_effect=lambda p: p == str(existing)), + patch("hate_crack.attacks.os.path.isdir", return_value=False), + ): wordlist_optimize(ctx) out = capsys.readouterr().out - assert "Files not found" in out + assert "Not found" in out ctx.wordlist_optimize.assert_not_called() def test_empty_outdir_rejection(self, tmp_path, capsys): @@ -370,7 +405,10 @@ class TestWordlistOptimize: wl = tmp_path / "a.txt" wl.write_text("word\n") ctx.select_file_with_autocomplete.side_effect = [str(wl), ""] - with patch("hate_crack.attacks.os.path.isfile", return_value=True): + with ( + patch("hate_crack.attacks.os.path.isfile", return_value=True), + patch("hate_crack.attacks.os.path.isdir", return_value=False), + ): wordlist_optimize(ctx) out = capsys.readouterr().out assert "Output directory cannot be empty" in out @@ -383,7 +421,10 @@ class TestWordlistOptimize: wl.write_text("word\n") outdir = str(tmp_path / "out") ctx.select_file_with_autocomplete.side_effect = [str(wl), outdir] - with patch("hate_crack.attacks.os.path.isfile", return_value=True): + with ( + patch("hate_crack.attacks.os.path.isfile", return_value=True), + patch("hate_crack.attacks.os.path.isdir", return_value=False), + ): wordlist_optimize(ctx) out = capsys.readouterr().out assert "Optimization failed" in out From b7c81a3f365543f42612f8e5591bfb0a686f43ca Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Tue, 5 May 2026 17:47:52 -0400 Subject: [PATCH 30/31] fix(hashview-download): append found hashes to left file, fix rsplit for NTLMv2 --- hate_crack/api.py | 8 +++++++- tests/test_hashview.py | 10 +++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/hate_crack/api.py b/hate_crack/api.py index f40121b..42bf757 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -1428,7 +1428,7 @@ class HashviewAPI: for line in f: line = line.strip() if line: - parts = line.split(":", 1) # Split on first colon + parts = line.rsplit(":", 1) if len(parts) == 2: hash_part, clear_part = parts hf.write(hash_part + "\n") @@ -1436,6 +1436,12 @@ class HashviewAPI: hashes_count += 1 clears_count += 1 + # Append found hashes to the left file to reconstruct the full hashlist + with open(output_abs, "a", encoding="utf-8") as lf: + with open(found_hashes_file, "r", encoding="utf-8") as hf: + for line in hf: + lf.write(line) + print( f"Split found file into {hashes_count} hashes and {clears_count} clears" ) diff --git a/tests/test_hashview.py b/tests/test_hashview.py index ed01f2a..0e346e6 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -644,14 +644,14 @@ class TestHashviewAPI: # Verify left file was created assert os.path.exists(result["output_file"]) - # Verify left file contains only the original uncracked hashes + # Verify left file contains the full original hashlist (left + found) with open(result["output_file"], "r") as f: left_contents = f.read() - assert "found_hash1" not in left_contents, ( - "Found hashes must NOT be written back into the left file" + assert "found_hash1" in left_contents, ( + "Found hashes must be appended to the left file to reconstruct the full hashlist" ) - assert "found_hash2" not in left_contents, ( - "Found hashes must NOT be written back into the left file" + assert "found_hash2" in left_contents, ( + "Found hashes must be appended to the left file to reconstruct the full hashlist" ) assert "uncracked_hash1" in left_contents assert "uncracked_hash2" in left_contents From 66e1f3935eabd123b961d8d7578388a2fc71cb0d Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Tue, 5 May 2026 18:36:47 -0400 Subject: [PATCH 31/31] test(hashview-download): strengthen assertions, add NTLMv2 rsplit coverage - Strengthen left-file assertions to verify hash-only lines and exclude passwords - Add test_download_left_rsplit_ntlmv2 to verify rsplit(":", 1) correctly handles multi-colon hashes like NTLMv2 potfile format Co-Authored-By: Claude Sonnet 4.6 --- tests/test_hashview.py | 50 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/tests/test_hashview.py b/tests/test_hashview.py index 0e346e6..b8d6905 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -647,11 +647,17 @@ class TestHashviewAPI: # Verify left file contains the full original hashlist (left + found) with open(result["output_file"], "r") as f: left_contents = f.read() - assert "found_hash1" in left_contents, ( - "Found hashes must be appended to the left file to reconstruct the full hashlist" + assert "found_hash1\n" in left_contents, ( + "Found hashes must be appended as hash-only lines" ) - assert "found_hash2" in left_contents, ( - "Found hashes must be appended to the left file to reconstruct the full hashlist" + assert "found_password1" not in left_contents, ( + "Plaintext passwords must not appear in the left file" + ) + assert "found_hash2\n" in left_contents, ( + "Found hashes must be appended as hash-only lines" + ) + assert "found_password2" not in left_contents, ( + "Plaintext passwords must not appear in the left file" ) assert "uncracked_hash1" in left_contents assert "uncracked_hash2" in left_contents @@ -677,6 +683,42 @@ class TestHashviewAPI: assert "found_hash1:found_password1" in potfile_contents assert "found_hash2:found_password2" in potfile_contents + def test_download_left_rsplit_ntlmv2(self, api, tmp_path, monkeypatch): + """rsplit correctly extracts the full NTLMv2 hash (which contains colons) from a found line.""" + potfile = str(tmp_path / "hashcat.potfile") + monkeypatch.setattr("hate_crack.api.get_hcat_potfile_path", lambda: potfile) + + ntlmv2_hash = "alice::DOMAIN:aabbccdd:ntproofstr:blob" + ntlmv2_found_line = f"{ntlmv2_hash}:s3cr3t\n" + + mock_left = Mock() + mock_left.content = b"some_other_hash\n" + mock_left.raise_for_status = Mock() + mock_left.headers = {"content-length": "0"} + mock_left.iter_content = lambda chunk_size=8192: iter([mock_left.content]) + + mock_found = Mock() + mock_found.content = ntlmv2_found_line.encode() + mock_found.raise_for_status = Mock() + mock_found.headers = {"content-length": "0"} + mock_found.iter_content = lambda chunk_size=8192: iter([mock_found.content]) + mock_found.status_code = 200 + + api.session.get.side_effect = [mock_left, mock_found] + + left_file = tmp_path / "left_1_2.txt" + api.download_left_hashes(1, 2, output_file=str(left_file)) + + with open(str(left_file), "r") as f: + contents = f.read() + + assert ntlmv2_hash + "\n" in contents, ( + "Full NTLMv2 hash (with colons) must be appended to the left file" + ) + assert "s3cr3t" not in contents, ( + "Plaintext password must not appear in the left file" + ) + def test_download_left_potfile_path_param_overrides_config(self, api, tmp_path): """Test that a passed-in potfile_path is used instead of re-reading config.""" mock_left_response = Mock()