fix(ui): route ollama/omen submenus through interactive_menu; re-prompt pickers on invalid input

- ollama_attack: convert generation-mode menu to interactive_menu with
  dynamic items list (option 3 only present when cracked file exists);
  cancel via 99 or Escape; re-prompts in a while loop
- omen_attack: convert existing-model menu to interactive_menu; cancel
  via 99 or Escape (replaces hard-coded "3. Cancel"); re-prompts in loop
- _omen_pick_training_wordlist: replace abort-on-invalid with re-prompt
  loop; add explicit 'q' cancel key so users can exit without being
  trapped; callers already handle None correctly
- _markov_pick_training_source: same re-prompt-loop + 'q' cancel fix;
  callers already handle None correctly
- Update all affected tests to patch interactive_menu at
  hate_crack.attacks.interactive_menu (module-level import path) and
  drive follow-up prompts via builtins.input
- Add new tests: arrow-menu reach, Escape/99 cancel, re-prompt loop
  coverage for both pickers, and conditional option-3 key presence

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Bollinger
2026-07-24 18:31:04 -04:00
co-authored by Claude
parent 39e0dd956a
commit 07ca6ba7e3
3 changed files with 326 additions and 125 deletions
+184 -24
View File
@@ -329,7 +329,10 @@ class TestOllamaAttack:
def test_calls_hcatOllama_with_context(self) -> None:
ctx = _make_ctx()
with patch("builtins.input", side_effect=["1", "ACME", "tech", "NYC"]):
with (
patch("hate_crack.attacks.interactive_menu", return_value="1"),
patch("builtins.input", side_effect=["ACME", "tech", "NYC"]),
):
ollama_attack(ctx)
ctx.hcatOllama.assert_called_once_with(
@@ -342,7 +345,10 @@ class TestOllamaAttack:
def test_passes_hash_type_and_file(self) -> None:
ctx = _make_ctx(hash_type="1800", hash_file="/tmp/sha512.txt")
with patch("builtins.input", side_effect=["1", "Corp", "finance", "London"]):
with (
patch("hate_crack.attacks.interactive_menu", return_value="1"),
patch("builtins.input", side_effect=["Corp", "finance", "London"]),
):
ollama_attack(ctx)
call_args = ctx.hcatOllama.call_args[0]
@@ -352,7 +358,10 @@ class TestOllamaAttack:
def test_strips_whitespace_from_inputs(self) -> None:
ctx = _make_ctx()
with patch("builtins.input", side_effect=["1", " ACME ", " tech ", " NYC "]):
with (
patch("hate_crack.attacks.interactive_menu", return_value="1"),
patch("builtins.input", side_effect=[" ACME ", " tech ", " NYC "]),
):
ollama_attack(ctx)
target_info = ctx.hcatOllama.call_args[0][3]
@@ -363,7 +372,10 @@ class TestOllamaAttack:
def test_target_string_is_literal_target(self) -> None:
ctx = _make_ctx()
with patch("builtins.input", side_effect=["1", "X", "Y", "Z"]):
with (
patch("hate_crack.attacks.interactive_menu", return_value="1"),
patch("builtins.input", side_effect=["X", "Y", "Z"]),
):
ollama_attack(ctx)
assert ctx.hcatOllama.call_args[0][2] == "target"
@@ -373,30 +385,44 @@ class TestOllamaAttack:
ctx.list_wordlist_files.return_value = ["rockyou.txt"]
ctx.hcatWordlists = "/tmp/wl"
# mode "2", then pick wordlist "1"
with patch("builtins.input", side_effect=["2", "1"]):
# mode "2" from interactive_menu, then pick wordlist "1" via input
with (
patch("hate_crack.attacks.interactive_menu", return_value="2"),
patch("builtins.input", side_effect=["1"]),
):
ollama_attack(ctx)
args = ctx.hcatOllama.call_args[0]
assert args[2] == "wordlist"
assert args[3].endswith("rockyou.txt")
def test_invalid_mode_does_not_call_hcatOllama(self) -> None:
def test_escape_cancels_without_calling_hcatOllama(self) -> None:
"""None from interactive_menu (Escape / 99) cancels the attack."""
ctx = _make_ctx()
with patch("builtins.input", side_effect=["9"]):
with patch("hate_crack.attacks.interactive_menu", return_value=None):
ollama_attack(ctx)
ctx.hcatOllama.assert_not_called()
def test_cancel_key_cancels_without_calling_hcatOllama(self) -> None:
"""Selecting '99' (Cancel) cancels the attack."""
ctx = _make_ctx()
with patch("hate_crack.attacks.interactive_menu", return_value="99"):
ollama_attack(ctx)
ctx.hcatOllama.assert_not_called()
def test_wordlist_mode_aborts_when_no_wordlist_picked(self) -> None:
ctx = _make_ctx()
# mode "2", then an invalid picker selection -> picker returns None
# mode "2" from interactive_menu, then user cancels the file picker
ctx.list_wordlist_files.return_value = []
with patch("builtins.input", side_effect=["2", "nonsense"]):
with (
patch("hate_crack.attacks.interactive_menu", return_value="2"),
patch("builtins.input", side_effect=["q"]),
):
ollama_attack(ctx)
ctx.hcatOllama.assert_not_called()
def test_cracked_mode_offered_when_out_file_has_content(
self, tmp_path: Path, capsys
self, tmp_path: Path
) -> None:
hash_file = tmp_path / "hashes.txt"
hash_file.touch()
@@ -404,43 +430,63 @@ class TestOllamaAttack:
out_file.write_text("hash:Summer2024!\n")
ctx = _make_ctx(hash_file=str(hash_file))
with patch("builtins.input", side_effect=["3"]):
captured_items: list[list[tuple[str, str]]] = []
def capture_menu(items, **kwargs):
captured_items.append(list(items))
return "3"
with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu):
ollama_attack(ctx)
assert "3. Cracked passwords" in capsys.readouterr().out
# Option 3 must be present in the items list when cracked file exists
keys = [k for k, _ in captured_items[0]]
assert "3" in keys
ctx.hcatOllama.assert_called_once_with(
ctx.hcatHashType, str(hash_file), "cracked", str(out_file)
)
def test_cracked_mode_not_offered_when_out_file_missing(
self, tmp_path: Path, capsys
self, tmp_path: Path
) -> None:
"""Option 3 must NOT appear in items when no cracked file exists."""
hash_file = tmp_path / "hashes.txt"
hash_file.touch()
ctx = _make_ctx(hash_file=str(hash_file))
with patch("builtins.input", side_effect=["3"]):
captured_items: list[list[tuple[str, str]]] = []
def capture_menu(items, **kwargs):
captured_items.append(list(items))
return "99" # cancel
with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu):
ollama_attack(ctx)
out = capsys.readouterr().out
assert "3. Cracked passwords" not in out
assert "No cracked passwords yet" in out
keys = [k for k, _ in captured_items[0]]
assert "3" not in keys
ctx.hcatOllama.assert_not_called()
def test_cracked_mode_not_offered_when_out_file_empty(
self, tmp_path: Path, capsys
self, tmp_path: Path
) -> None:
"""Option 3 must NOT appear in items when cracked file exists but is empty."""
hash_file = tmp_path / "hashes.txt"
hash_file.touch()
(tmp_path / "hashes.txt.out").touch() # exists but zero bytes
ctx = _make_ctx(hash_file=str(hash_file))
with patch("builtins.input", side_effect=["3"]):
captured_items: list[list[tuple[str, str]]] = []
def capture_menu(items, **kwargs):
captured_items.append(list(items))
return "99" # cancel
with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu):
ollama_attack(ctx)
out = capsys.readouterr().out
assert "3. Cracked passwords" not in out
assert "No cracked passwords yet" in out
keys = [k for k, _ in captured_items[0]]
assert "3" not in keys
ctx.hcatOllama.assert_not_called()
def test_target_and_wordlist_modes_unaffected_by_cracked_option(
@@ -452,7 +498,121 @@ class TestOllamaAttack:
(tmp_path / "hashes.txt.out").write_text("hash:Summer2024!\n")
ctx = _make_ctx(hash_file=str(hash_file))
with patch("builtins.input", side_effect=["1", "ACME", "tech", "NYC"]):
with (
patch("hate_crack.attacks.interactive_menu", return_value="1"),
patch("builtins.input", side_effect=["ACME", "tech", "NYC"]),
):
ollama_attack(ctx)
assert ctx.hcatOllama.call_args[0][2] == "target"
def test_arrow_menu_env_reaches_ollama_attack(self) -> None:
"""HATE_CRACK_ARROW_MENU=1 routes through interactive_menu in ollama_attack."""
import os
from hate_crack.attacks import interactive_menu as real_im
ctx = _make_ctx()
calls: list[tuple] = []
def spy_menu(items, **kwargs):
calls.append(tuple(items))
return "99" # cancel immediately
with (
patch("hate_crack.attacks.interactive_menu", side_effect=spy_menu),
):
ollama_attack(ctx)
# interactive_menu was called — confirms the arrow-menu code path is reachable
assert len(calls) == 1
keys = [k for k, _ in calls[0]]
assert "1" in keys
assert "2" in keys
assert "99" in keys
class TestOmenPickTrainingWordlistReprompt:
"""_omen_pick_training_wordlist re-prompts on invalid input instead of aborting."""
def _make_ctx(self, wordlist_files=None):
ctx = MagicMock()
ctx.list_wordlist_files.return_value = wordlist_files or ["rockyou.txt"]
ctx.hcatWordlists = "/tmp/wl"
ctx.hcatHashFile = "/tmp/hashes.txt"
return ctx
def test_invalid_input_reprompts_then_valid_pick(self) -> None:
from hate_crack.attacks import _omen_pick_training_wordlist
ctx = self._make_ctx(["rockyou.txt"])
# First input is invalid, second is valid
with patch("builtins.input", side_effect=["bad", "1"]):
result = _omen_pick_training_wordlist(ctx)
assert result is not None
assert "rockyou.txt" in result
def test_cancel_with_q_returns_none(self) -> None:
from hate_crack.attacks import _omen_pick_training_wordlist
ctx = self._make_ctx(["rockyou.txt"])
with patch("builtins.input", return_value="q"):
result = _omen_pick_training_wordlist(ctx)
assert result is None
def test_multiple_invalid_inputs_then_cancel(self) -> None:
from hate_crack.attacks import _omen_pick_training_wordlist
ctx = self._make_ctx(["rockyou.txt"])
with patch("builtins.input", side_effect=["99", "abc", "q"]):
result = _omen_pick_training_wordlist(ctx)
assert result is None
class TestMarkovPickTrainingSourceReprompt:
"""_markov_pick_training_source re-prompts on invalid input instead of aborting."""
def _make_ctx(self, tmp_path, has_cracked=False, wordlist_files=None):
ctx = MagicMock()
hash_file = str(tmp_path / "hashes.txt")
ctx.hcatHashFile = hash_file
ctx.list_wordlist_files.return_value = wordlist_files or ["rockyou.txt"]
ctx.hcatWordlists = str(tmp_path / "wordlists")
if has_cracked:
(tmp_path / "hashes.txt.out").write_text("cracked_pw\n")
return ctx
def test_invalid_input_reprompts_then_valid_pick(self, tmp_path: Path) -> None:
from hate_crack.attacks import _markov_pick_training_source
ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"])
with patch("builtins.input", side_effect=["bad", "1"]):
result = _markov_pick_training_source(ctx)
assert result is not None
assert "rockyou.txt" in result
def test_cancel_with_q_returns_none(self, tmp_path: Path) -> None:
from hate_crack.attacks import _markov_pick_training_source
ctx = self._make_ctx(tmp_path)
with patch("builtins.input", return_value="q"):
result = _markov_pick_training_source(ctx)
assert result is None
def test_multiple_invalid_then_cancel(self, tmp_path: Path) -> None:
from hate_crack.attacks import _markov_pick_training_source
ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"])
with patch("builtins.input", side_effect=["99", "abc", "q"]):
result = _markov_pick_training_source(ctx)
assert result is None
def test_caller_handles_none_correctly(self, tmp_path: Path) -> None:
"""markov_brute_force returns early without crashing when picker returns None."""
from hate_crack.attacks import markov_brute_force
ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"])
# No .hcstat2 file → goes straight to picker; user cancels
with patch("builtins.input", return_value="q"):
markov_brute_force(ctx)
ctx.hcatMarkovTrain.assert_not_called()
ctx.hcatMarkovBruteForce.assert_not_called()