fix(llm): add spinner during Ollama generation and cap wordlist sample

Defect 1 — hcatOllama showed a plain print then blocked silently for
up to 300 s while waiting for the LLM.  A new generic context manager
`hate_crack/progress.py::spinner()` now runs a daemon thread that
repaints a single line with a frame + elapsed-seconds counter every
~120 ms.  TTY guard ensures non-TTY/test environments get a single
plain print instead of ANSI control characters.  The line is erased
with \033[2K\r on exit (normal and exceptional).

Defect 2 — the wordlist path materialised every line in memory and
built a prompt that could massively overflow ollamaNumCtx on large
wordlists.  The path now does a two-pass evenly-spaced sample: pass 1
counts usable lines, pass 2 stride-selects up to `ollamaMaxSampleLines`
(default 500, new config key) spread across the full file.  When no
capping occurs the message reads "Loaded N passwords"; when capping
occurs it reads "Sampled N of M passwords".

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Bollinger
2026-07-24 17:49:24 -04:00
co-authored by Claude
parent e88315dfb9
commit e5e1107359
5 changed files with 472 additions and 21 deletions
+1
View File
@@ -28,6 +28,7 @@
"ollamaModel": "qwen2.5:32b",
"ollamaNumCtx": 2048,
"ollamaTimeout": 300,
"ollamaMaxSampleLines": 500,
"omenTrainingList": "rockyou.txt",
"omenMaxCandidates": 50000000,
"pcfgRuleset": "DEFAULT",
+58 -12
View File
@@ -74,6 +74,7 @@ from hate_crack.cli import ( # noqa: E402
)
from hate_crack import attacks as _attacks # noqa: E402
from hate_crack import llm # noqa: E402
from hate_crack.progress import spinner # noqa: E402
from hate_crack.menu import interactive_menu # noqa: E402
from hate_crack.username_detect import detect_username_hash_format # noqa: E402
@@ -453,6 +454,7 @@ ollamaUrl = "http://" + os.environ.get("OLLAMA_HOST", "localhost:11434")
ollamaModel = config_parser.get("ollamaModel", "qwen2.5:32b")
ollamaNumCtx = int(config_parser.get("ollamaNumCtx", 2048))
ollamaTimeout = float(config_parser.get("ollamaTimeout", 300))
ollamaMaxSampleLines = int(config_parser.get("ollamaMaxSampleLines", 500))
omenTrainingList = config_parser.get("omenTrainingList", "rockyou.txt")
omenMaxCandidates = int(config_parser.get("omenMaxCandidates", 1000000))
@@ -2046,23 +2048,67 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
if not os.path.isfile(wordlist_path):
print(f"Error: Wordlist not found: {wordlist_path}")
return
lines = []
try:
with open(wordlist_path, "r", errors="ignore") as f:
for line in f:
stripped = line.strip()
# Two-pass evenly-spaced sample: first count usable lines so we can
# stride-select across the whole file rather than taking a head slice.
# A head-only sample misses the pattern variation across large wordlists
# (e.g. rockyou.txt becomes more random further in).
def _usable(raw: str) -> str:
"""Return the usable plaintext from a raw line, or empty string."""
stripped = raw.strip()
if not stripped:
continue
# hash:password -> password
return ""
if ":" in stripped:
stripped = stripped.split(":", 1)[1]
if stripped:
lines.append(stripped)
return stripped
try:
total_usable = 0
with open(wordlist_path, "r", errors="ignore") as f:
for raw in f:
if _usable(raw):
total_usable += 1
except Exception as e:
print(f"Error reading wordlist: {e}")
return
print(f"Loaded {len(lines)} passwords from wordlist.")
gen_context = {"sample": "\n".join(lines)}
cap = ollamaMaxSampleLines
if total_usable <= cap:
# No capping needed — collect all usable lines.
try:
sampled: list[str] = []
with open(wordlist_path, "r", errors="ignore") as f:
for raw in f:
w = _usable(raw)
if w:
sampled.append(w)
except Exception as e:
print(f"Error reading wordlist: {e}")
return
print(f"Loaded {len(sampled):,} passwords from wordlist.")
else:
# Evenly-spaced stride across the file to cover its full pattern
# range without materialising all lines in memory.
stride = total_usable / cap
try:
sampled = []
usable_idx = 0.0
next_pick = stride / 2 # start near centre of first bucket
with open(wordlist_path, "r", errors="ignore") as f:
for raw in f:
w = _usable(raw)
if not w:
continue
if usable_idx >= next_pick and len(sampled) < cap:
sampled.append(w)
next_pick += stride
usable_idx += 1
except Exception as e:
print(f"Error reading wordlist: {e}")
return
print(f"Sampled {len(sampled):,} of {total_usable:,} passwords from wordlist.")
gen_context = {"sample": "\n".join(sampled)}
elif mode == "target":
gen_context = context_data
else:
@@ -2070,8 +2116,8 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
return
# Step B: generate candidates via the Atomic Agents module.
print(f"Generating password candidates via Ollama ({ollamaModel})...")
try:
with spinner(f"Generating password candidates via Ollama ({ollamaModel})..."):
candidates = llm.generate_candidates(
ollamaUrl,
ollamaModel,
+65
View File
@@ -0,0 +1,65 @@
"""Generic terminal progress utilities for hate_crack.
Provides a context manager that shows a live spinner with elapsed-seconds
counter while a blocking operation runs. Safe to use in non-TTY environments:
if stdout is not a TTY the message is printed once and no background thread is
started.
"""
from __future__ import annotations
import sys
import threading
import time
from collections.abc import Generator
from contextlib import contextmanager
_SPINNER_FRAMES = ["|", "/", "-", "\\"]
_TICK_INTERVAL = 0.12 # seconds between repaints (~120 ms)
@contextmanager
def spinner(message: str) -> "Generator[None, None, None]":
"""Context manager that shows *message* plus a live elapsed-seconds counter.
While the body executes a daemon thread repaints a single terminal line
roughly every 120 ms showing:
| Generating password candidates via Ollama (model)... 3s
On exit (normal or exceptional) the spinner line is erased so subsequent
output starts on a clean line.
TTY guard: if ``sys.stdout.isatty()`` is False the message is printed once
via ``print()`` and no thread is started, keeping piped output and the test
suite clean.
"""
if not sys.stdout.isatty():
print(message)
yield
return
stop_event = threading.Event()
start_time = time.monotonic()
def _run() -> None:
frame_idx = 0
while not stop_event.is_set():
elapsed = int(time.monotonic() - start_time)
frame = _SPINNER_FRAMES[frame_idx % len(_SPINNER_FRAMES)]
line = f"\r{frame} {message} {elapsed}s"
sys.stdout.write(line)
sys.stdout.flush()
frame_idx += 1
stop_event.wait(_TICK_INTERVAL)
thread = threading.Thread(target=_run, daemon=True)
thread.start()
try:
yield
finally:
stop_event.set()
thread.join()
# Erase the spinner line so the next print starts clean.
sys.stdout.write("\033[2K\r")
sys.stdout.flush()
+231
View File
@@ -0,0 +1,231 @@
"""Tests for the capped / evenly-sampled wordlist path in hcatOllama.
Covers:
- ollamaMaxSampleLines config default (500)
- Small wordlist (< cap): all lines included, "Loaded N" message
- Large wordlist (> cap): exactly `cap` lines sampled, "Sampled N of M" message
- Evenly-spaced sample covers the whole file (first and last entries present)
- hash:password splitting and blank-line skipping still work
- spinner is called with the right message regardless of TTY
"""
from __future__ import annotations
import os
from contextlib import contextmanager
from types import SimpleNamespace
from unittest import mock
import pytest
os.environ["HATE_CRACK_SKIP_INIT"] = "1"
from hate_crack import main as hc_main # noqa: E402
OLLAMA_URL = "http://localhost:11434"
MODEL = "test-model"
@contextmanager
def _ollama_globals(tmp_path, *, max_sample: int = 500):
rules_dir = str(tmp_path / "rules")
os.makedirs(rules_dir, exist_ok=True)
with (
mock.patch.object(hc_main, "ollamaUrl", OLLAMA_URL),
mock.patch.object(hc_main, "ollamaModel", MODEL),
mock.patch.object(hc_main, "ollamaNumCtx", 2048),
mock.patch.object(hc_main, "ollamaMaxSampleLines", max_sample),
mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"),
mock.patch.object(hc_main, "hcatTuning", ""),
mock.patch.object(hc_main, "hcatPotfilePath", ""),
mock.patch.object(hc_main, "rulesDirectory", rules_dir),
mock.patch("hate_crack.main.generate_session_id", return_value="s"),
):
yield
def _make_proc(rc: int = 0):
proc = mock.MagicMock()
proc.wait.return_value = rc
proc.communicate.return_value = (b"", b"")
proc.returncode = rc
return proc
@pytest.fixture
def env(tmp_path):
hash_file = tmp_path / "hashes.txt"
hash_file.touch()
return SimpleNamespace(tmp_path=tmp_path, hash_file=str(hash_file))
# ---------------------------------------------------------------------------
# Config default
# ---------------------------------------------------------------------------
def test_ollama_max_sample_lines_default():
"""ollamaMaxSampleLines must default to 500."""
assert hc_main.ollamaMaxSampleLines == 500
# ---------------------------------------------------------------------------
# Small wordlist — no capping
# ---------------------------------------------------------------------------
def test_small_wordlist_loads_all(env, capsys):
"""When total lines < cap, all are included and message says 'Loaded'."""
wl = env.tmp_path / "small.txt"
wl.write_text("alpha\nbeta\ngamma\n")
with _ollama_globals(env.tmp_path), mock.patch.object(
hc_main.llm, "generate_candidates", return_value=["x"]
) as gen, mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl))
captured = capsys.readouterr()
assert "Loaded 3" in captured.out
# All three words must appear in the sample passed to the LLM
sample = gen.call_args[0][4]["sample"]
assert "alpha" in sample
assert "beta" in sample
assert "gamma" in sample
def test_small_wordlist_no_sampled_message(env, capsys):
"""'Sampled N of M' must NOT appear when no capping occurred."""
wl = env.tmp_path / "small.txt"
wl.write_text("a\nb\nc\n")
with _ollama_globals(env.tmp_path, max_sample=500), mock.patch.object(
hc_main.llm, "generate_candidates", return_value=["x"]
), mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl))
captured = capsys.readouterr()
assert "Sampled" not in captured.out
# ---------------------------------------------------------------------------
# Large wordlist — capping
# ---------------------------------------------------------------------------
def _make_large_wordlist(path, n: int) -> None:
"""Write n unique numbered lines to path."""
lines = "\n".join(f"word{i:06d}" for i in range(n))
path.write_text(lines + "\n")
def test_large_wordlist_caps_to_max(env, capsys):
"""When total > cap, exactly cap lines are sampled."""
wl = env.tmp_path / "big.txt"
_make_large_wordlist(wl, 1000)
captured_ctx: list[dict] = []
def _capture_gen(*args, **kwargs):
captured_ctx.append(args[4])
return ["x"]
with _ollama_globals(env.tmp_path, max_sample=50), mock.patch.object(
hc_main.llm, "generate_candidates", side_effect=_capture_gen
), mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl))
sample_lines = captured_ctx[0]["sample"].splitlines()
assert len(sample_lines) == 50
captured = capsys.readouterr()
assert "Sampled 50 of 1,000" in captured.out
def test_large_wordlist_covers_full_range(env):
"""Evenly-spaced sample must contain entries from both the start and end of the file."""
wl = env.tmp_path / "range.txt"
_make_large_wordlist(wl, 1000)
captured_ctx: list[dict] = []
def _capture_gen(*args, **kwargs):
captured_ctx.append(args[4])
return ["x"]
# Use a small cap (10) over 1000 lines so the stride is clearly 100.
with _ollama_globals(env.tmp_path, max_sample=10), mock.patch.object(
hc_main.llm, "generate_candidates", side_effect=_capture_gen
), mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl))
sample_lines = captured_ctx[0]["sample"].splitlines()
# The sampled words should come from across the file, not just the head.
# word000000..word000099 are the first 100; word000900..word000999 are the last 100.
indices = [int(w.replace("word", "")) for w in sample_lines]
assert min(indices) < 100, "sample should include entries from the start of the file"
assert max(indices) >= 900, "sample should include entries from the end of the file"
# ---------------------------------------------------------------------------
# hash:password splitting and blank-line skipping
# ---------------------------------------------------------------------------
def test_wordlist_sampling_strips_hash_prefix(env):
"""hash:password lines must contribute only the plaintext in capped mode."""
wl = env.tmp_path / "dump.txt"
# 20 lines: half with colon prefix, half plain; more than max_sample=5
lines = [f"hash{i}:plain{i:02d}" if i % 2 == 0 else f"plain{i:02d}" for i in range(20)]
wl.write_text("\n".join(lines) + "\n")
captured_ctx: list[dict] = []
def _capture_gen(*args, **kwargs):
captured_ctx.append(args[4])
return ["x"]
with _ollama_globals(env.tmp_path, max_sample=5), mock.patch.object(
hc_main.llm, "generate_candidates", side_effect=_capture_gen
), mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl))
sample = captured_ctx[0]["sample"]
# No hash prefix should appear in the sample
assert "hash" not in sample
def test_wordlist_sampling_skips_blank_lines(env, capsys):
"""Blank lines must not count toward total or be included in the sample."""
wl = env.tmp_path / "blanks.txt"
# 3 real words surrounded by blank lines
wl.write_text("\nalpha\n\nbeta\n\ngamma\n\n")
with _ollama_globals(env.tmp_path, max_sample=500), mock.patch.object(
hc_main.llm, "generate_candidates", return_value=["x"]
) as gen, mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl))
captured = capsys.readouterr()
assert "Loaded 3" in captured.out
sample = gen.call_args[0][4]["sample"]
for line in sample.splitlines():
assert line.strip() != "", "blank lines leaked into sample"
# ---------------------------------------------------------------------------
# Spinner is invoked (via the TTY guard — non-TTY in test suite)
# ---------------------------------------------------------------------------
def test_spinner_called_with_model_message(env, capsys):
"""The spinner message must include the model name (non-TTY: just print)."""
wl = env.tmp_path / "s.txt"
wl.write_text("pw\n")
with _ollama_globals(env.tmp_path), mock.patch.object(
hc_main.llm, "generate_candidates", return_value=["x"]
), mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl))
captured = capsys.readouterr()
assert MODEL in captured.out
assert "Generating password candidates via Ollama" in captured.out
+108
View File
@@ -0,0 +1,108 @@
"""Tests for hate_crack/progress.py — the generic spinner context manager."""
from __future__ import annotations
import io
import sys
import threading
import time
from unittest import mock
import pytest
from hate_crack.progress import spinner
# ---------------------------------------------------------------------------
# TTY guard: non-TTY path must print message once and not start a thread
# ---------------------------------------------------------------------------
def test_non_tty_prints_message(capsys: pytest.CaptureFixture[str]) -> None:
"""In a non-TTY environment the message is printed once and no thread runs."""
with mock.patch.object(sys, "stdout", wraps=sys.stdout) as m_stdout:
m_stdout.isatty.return_value = False # type: ignore[attr-defined]
# Use actual capsys for the print capture but override isatty
original_isatty = sys.stdout.isatty
sys.stdout.isatty = lambda: False # type: ignore[method-assign]
try:
with spinner("Working..."):
pass
finally:
sys.stdout.isatty = original_isatty # type: ignore[method-assign]
captured = capsys.readouterr()
assert "Working..." in captured.out
def test_non_tty_no_extra_thread() -> None:
"""Spinner in non-TTY context should not start a background thread."""
buf = io.StringIO()
buf.isatty = lambda: False # type: ignore[attr-defined]
threads_before = threading.active_count()
with mock.patch("sys.stdout", buf):
with spinner("No threads please"):
peak = threading.active_count()
# The thread count during the body should not exceed baseline + 1
# (pytest itself may add threads; we just want no extra daemon spinner thread).
assert peak <= threads_before + 1
# ---------------------------------------------------------------------------
# TTY path: spinner starts a thread, clears the line on exit
# ---------------------------------------------------------------------------
def test_tty_clears_line_on_exit() -> None:
"""After the context exits the spinner line must be erased."""
buf = io.StringIO()
buf.isatty = lambda: True # type: ignore[attr-defined]
with mock.patch("sys.stdout", buf):
with spinner("Testing..."):
time.sleep(0.05) # let spinner tick at least once
output = buf.getvalue()
# Line clear sequence must be present somewhere after the spinner started.
assert "\033[2K\r" in output or ("\033[2K" in output)
def test_tty_shows_elapsed_seconds() -> None:
"""The spinner should show at least a 0s counter in its output."""
buf = io.StringIO()
buf.isatty = lambda: True # type: ignore[attr-defined]
with mock.patch("sys.stdout", buf):
with spinner("Counting..."):
time.sleep(0.25) # enough time for several ticks
output = buf.getvalue()
# Should have written at least one elapsed counter like "0s" or "1s"
assert "s" in output
assert "Counting..." in output
def test_tty_exception_still_clears_line() -> None:
"""Spinner must clear the line even when the body raises."""
buf = io.StringIO()
buf.isatty = lambda: True # type: ignore[attr-defined]
with mock.patch("sys.stdout", buf):
with pytest.raises(ValueError):
with spinner("Will raise"):
raise ValueError("boom")
output = buf.getvalue()
assert "\033[2K\r" in output or ("\033[2K" in output)
def test_spinner_does_not_swallow_exception() -> None:
"""The spinner context manager must re-raise exceptions from the body."""
buf = io.StringIO()
buf.isatty = lambda: True # type: ignore[attr-defined]
with mock.patch("sys.stdout", buf):
with pytest.raises(RuntimeError, match="test error"):
with spinner("Should propagate"):
raise RuntimeError("test error")