diff --git a/hate_crack/config.json.example b/hate_crack/config.json.example index c20f6f8..0bd02d7 100644 --- a/hate_crack/config.json.example +++ b/hate_crack/config.json.example @@ -22,6 +22,10 @@ "hashview_url": "http://localhost:8443", "hashview_api_key": "", "hashmob_api_key": "", + "ollamaModel": "qwen2.5:32b", + "ollamaNumCtx": 2048, + "ollamaTimeout": 300, + "ollamaMaxSampleLines": 500, "passgptModel": "javirandor/passgpt-10characters", "passgptMaxCandidates": 1000000, "passgptBatchSize": 1024, diff --git a/hate_crack/main.py b/hate_crack/main.py index 9e839f4..2cce2d2 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -897,6 +897,23 @@ def _wordlist_path(path: str): yield path +def _usable_plaintext(raw: str) -> str: + """Return the usable plaintext from a raw wordlist line, or empty string. + + Blank/whitespace-only lines are discarded. Lines in ``hash:password`` + format (as produced by hashcat ``--show``) are split on the first colon + so only the plaintext portion is returned; lines with no colon are + returned as-is. A ``hash:`` line whose plaintext is empty after + stripping returns an empty string and is therefore also discarded. + """ + stripped = raw.strip() + if not stripped: + return "" + if ":" in stripped: + stripped = stripped.split(":", 1)[1] + return stripped + + def _add_debug_mode_for_rules(cmd): """Add debug mode arguments to hashcat command if rules are being used. @@ -2053,27 +2070,18 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): # 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: - return "" - if ":" in stripped: - stripped = stripped.split(":", 1)[1] - return stripped - try: total_usable = 0 with open(wordlist_path, "r", errors="ignore") as f: for raw in f: - if _usable(raw): + if _usable_plaintext(raw): total_usable += 1 except Exception as e: print(f"Error reading wordlist: {e}") return cap = ollamaMaxSampleLines - # Defect 2: cap <= 0 is nonsense; treat it as "no cap" (default 500). + # Invalid cap (zero or negative): fall back to the built-in default of 500. if cap <= 0: cap = 500 if total_usable <= cap: @@ -2082,7 +2090,7 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): sampled: list[str] = [] with open(wordlist_path, "r", errors="ignore") as f: for raw in f: - w = _usable(raw) + w = _usable_plaintext(raw) if w: sampled.append(w) except Exception as e: @@ -2101,7 +2109,7 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): usable_idx = 0 with open(wordlist_path, "r", errors="ignore") as f: for raw in f: - w = _usable(raw) + w = _usable_plaintext(raw) if not w: continue if usable_idx in pick_set: diff --git a/hate_crack/progress.py b/hate_crack/progress.py index 3827533..0bb33a6 100644 --- a/hate_crack/progress.py +++ b/hate_crack/progress.py @@ -59,7 +59,12 @@ def spinner(message: str) -> "Generator[None, None, None]": yield finally: stop_event.set() - thread.join() - # Erase the spinner line so the next print starts clean. + # Clear the spinner line *before* joining so the terminal is left clean + # even if join() is interrupted by a second Ctrl-C (DoubleInterrupt). + # hate_crack's _sigint_handler raises DoubleInterrupt on a second SIGINT + # within 2 s, and join() can block up to _TICK_INTERVAL (0.12 s) — a + # narrow but real window. Writing the escape sequence first ensures the + # line is always erased regardless of what happens to the join. sys.stdout.write("\033[2K\r") sys.stdout.flush() + thread.join() diff --git a/tests/test_ollama_wordlist_sampling.py b/tests/test_ollama_wordlist_sampling.py index 2d232b7..b123247 100644 --- a/tests/test_ollama_wordlist_sampling.py +++ b/tests/test_ollama_wordlist_sampling.py @@ -250,7 +250,7 @@ def test_stride_cap_one(env) -> None: # --------------------------------------------------------------------------- -# Defect 2: ZeroDivisionError guard — cap=0 falls back to 500 +# Invalid-cap guard — zero/negative ollamaMaxSampleLines falls back to 500 # --------------------------------------------------------------------------- @@ -338,3 +338,38 @@ def test_spinner_called_with_model_message(env, capsys): captured = capsys.readouterr() assert MODEL in captured.out assert "Generating password candidates via Ollama" in captured.out + + +# --------------------------------------------------------------------------- +# _usable_plaintext unit tests +# --------------------------------------------------------------------------- + + +def test_usable_plaintext_blank_line(): + """A blank line must return an empty string (discarded).""" + assert hc_main._usable_plaintext("") == "" + + +def test_usable_plaintext_whitespace_only(): + """A whitespace-only line must return an empty string (discarded).""" + assert hc_main._usable_plaintext(" \t ") == "" + + +def test_usable_plaintext_plain_password(): + """A plain password line (no colon) is returned stripped.""" + assert hc_main._usable_plaintext("hunter2\n") == "hunter2" + + +def test_usable_plaintext_hash_colon_password(): + """A hash:password line returns only the password portion.""" + assert hc_main._usable_plaintext("aabbcc:hunter2") == "hunter2" + + +def test_usable_plaintext_multiple_colons(): + """A line with multiple colons splits only on the first colon.""" + assert hc_main._usable_plaintext("aabbcc:p@ss:word") == "p@ss:word" + + +def test_usable_plaintext_hash_colon_empty(): + """A hash: line with no plaintext after the colon returns empty string.""" + assert hc_main._usable_plaintext("aabbcc:") == "" diff --git a/tests/test_progress_spinner.py b/tests/test_progress_spinner.py index 64a0eed..c54c68b 100644 --- a/tests/test_progress_spinner.py +++ b/tests/test_progress_spinner.py @@ -3,6 +3,7 @@ from __future__ import annotations import io +import re import sys import time from unittest import mock @@ -55,7 +56,7 @@ def test_tty_clears_line_on_exit() -> None: with mock.patch("sys.stdout", buf): with spinner("Testing..."): - time.sleep(0.05) # let spinner tick at least once + pass # no sleep needed — the finally block writes the escape regardless output = buf.getvalue() # Line clear sequence must be present somewhere after the spinner started. @@ -63,18 +64,29 @@ def test_tty_clears_line_on_exit() -> None: def test_tty_shows_elapsed_seconds() -> None: - """The spinner should show at least a 0s counter in its output.""" + """The spinner must render the elapsed-seconds counter in the expected format. + + time.monotonic is patched so the test is deterministic regardless of + scheduling: the first call returns 0.0 (start), the second returns 3.0 + (first tick), giving a stable "3s" label without sleeping. + """ buf = io.StringIO() buf.isatty = lambda: True # type: ignore[attr-defined] - with mock.patch("sys.stdout", buf): + # Patch monotonic: start=0.0, then 3.0 at the first tick, then keep + # returning 3.0 so stop_event.wait() ends quickly. + mono_values = iter([0.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0]) + + with mock.patch("sys.stdout", buf), mock.patch( + "hate_crack.progress.time.monotonic", side_effect=mono_values + ): with spinner("Counting..."): - time.sleep(0.25) # enough time for several ticks + pass # body exits immediately; the thread gets one tick then stops output = buf.getvalue() - # Should have written at least one elapsed counter like "0s" or "1s" - assert "s" in output assert "Counting..." in output + # At least one frame must match the "Ns" elapsed format (e.g. "3s"). + assert re.search(r"\d+s", output), f"Expected elapsed counter in output: {output!r}" def test_tty_exception_still_clears_line() -> None: @@ -100,3 +112,40 @@ def test_spinner_does_not_swallow_exception() -> None: with pytest.raises(RuntimeError, match="test error"): with spinner("Should propagate"): raise RuntimeError("test error") + + +# --------------------------------------------------------------------------- +# Teardown robustness: line must be cleared even if join() is interrupted +# --------------------------------------------------------------------------- + + +def test_tty_clears_line_even_if_join_raises() -> None: + """Line-clear must happen even when thread.join() raises (e.g. DoubleInterrupt). + + The fix moves the write("\033[2K\r") *before* thread.join() so an + exception during join cannot prevent the terminal from being cleaned up. + """ + buf = io.StringIO() + buf.isatty = lambda: True # type: ignore[attr-defined] + + class FakeThread: + def __init__(self, *args, **kwargs) -> None: + pass + + def start(self) -> None: + pass + + def join(self, *args, **kwargs) -> None: + raise KeyboardInterrupt("simulated double Ctrl-C during join") + + with mock.patch("hate_crack.progress.threading.Thread", FakeThread), mock.patch( + "sys.stdout", buf + ): + with pytest.raises(KeyboardInterrupt): + with spinner("Robust teardown"): + pass + + output = buf.getvalue() + assert "\033[2K\r" in output, ( + "Line-clear escape sequence must be written before join() is called" + )