Merge latest main into ty bump for fix pass

This commit is contained in:
Justin Bollinger
2026-07-27 13:50:30 -04:00
15 changed files with 610 additions and 122 deletions
+6
View File
@@ -19,3 +19,9 @@ research/
4_char_all
all_hashes.enabled
some_histories
# Local agent tooling and instructions - intentionally not published
CLAUDE.md
.claude/
docs/plans/
docs/superpowers/
+53
View File
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Dates are omitted for releases predating this file; see the git tags for exact timing.
## [2.14.3] - 2026-07-25
### Added
- **Private-key commit gate.** `prek.toml` now runs the `detect-private-key`
hook at the pre-commit stage. The repo previously had no secret-scanning gate
of any kind — bandit only covers `hate_crack/`, so nothing inspected config
files, docs, or test fixtures for committed key material.
### Removed
- **Local agent tooling is no longer published.** `CLAUDE.md`, `.claude/`,
`docs/plans/`, and `docs/superpowers/` were development aids rather than part
of the shipped project. They are now gitignored and were removed from the
repository, including from its history.
- **`audit-docs` post-commit hook.** Dropped from `prek.toml` along with the
`.claude/audit-docs.sh` script it invoked.
## [2.14.2] - 2026-07-25
### Fixed
- **Pipal base-word parsing.** `pipal()` built one rigid regex that required
*exactly* `pipal_count` consecutive base-word lines, so any cracked set with
fewer unique base words than `pipal_count` (default 10 — the common case on
small cracks) matched nothing and returned no base words. The `Top N base
words` section is now parsed line by line, returning up to `pipal_count`
words and stopping at the end of the section.
- **Shell-safe pipal invocation.** The pipal subprocess is now spawned with
list-form arguments instead of a `shell=True` formatted string, so hash-file
paths containing shell metacharacters can no longer be interpreted as
commands.
### Changed
- Renamed the internal `_omen_pick_training_wordlist` helper to
`_pick_training_wordlist`, since it is shared by the OMEN, Markov-adjacent,
and LLM (wordlist mode) attacks rather than being OMEN-specific.
## [2.14.1] - 2026-07-25
### Fixed
- **Tab completion on custom file-path prompts.** The `p. Enter a custom path`
branches of the OMEN and Markov training pickers, the combipow wordlist
prompt, and the rule cleanup/optimize output-path prompts used a bare
`input()` with no readline completer, so TAB did nothing. They now route
through `select_file_with_autocomplete` for consistent path autocompletion.
- **Stale completer leak.** `select_file_with_autocomplete` and the
`_configure_readline`-based pickers now drop the path completer after a
selection, so later numeric-menu and y/n prompts no longer inherit file-path
tab completion.
## [2.14.0] - 2026-07-24
### Added
+72 -17
View File
@@ -27,7 +27,7 @@ Or download a pre-built binary from https://hashcat.net/hashcat/ and set `hcatPa
### 2. Download hate_crack
Clone with submodules (required for hashcat-utils, princeprocessor, and optionally omen):
Clone with submodules (required for hashcat-utils, princeprocessor, pcfg_cracker, and optionally omen):
```bash
git clone --recurse-submodules https://github.com/trustedsec/hate_crack.git
@@ -46,7 +46,7 @@ Then customize configuration in `config.json` if needed (wordlist paths, API key
The easiest way is to run `make` (or `make install`), which auto-detects your OS and installs:
- External dependencies (p7zip, transmission-daemon / transmission-remote)
- Builds submodules (hashcat-utils, princeprocessor, and optionally omen)
- Builds submodules (hashcat-utils, princeprocessor, pcfg_cracker, and optionally omen)
- Python dependencies via uv and a CLI shim at `~/.local/bin/hate_crack`
```bash
@@ -96,6 +96,12 @@ Core logic is now split into modules under `hate_crack/`:
- `hate_crack/api.py`: Hashview, Weakpass, and Hashmob integrations (downloads/menus/helpers).
- `hate_crack/attacks.py`: menu attack handlers.
- `hate_crack/hashmob_wordlist.py`: Hashmob wordlist utilities (thin wrapper; calls into api.py).
- `hate_crack/llm.py`: structured (JSON) LLM candidate generation via Atomic Agents.
- `hate_crack/menu.py`: shared menu renderer, including optional arrow-key navigation.
- `hate_crack/noninteractive.py`: dispatcher for the scripted attack subcommands.
- `hate_crack/notify/`: notification package (Pushover backend, per-crack tailer).
- `hate_crack/username_detect.py`: detects `username:hash` input files to decide on hashcat's `--username`.
- `hate_crack/formatting.py`, `hate_crack/progress.py`: output formatting and progress display helpers.
- `hate_crack/main.py`: main CLI implementation.
The top-level `hate_crack.py` remains the main entry point and orchestrates these modules.
@@ -143,7 +149,7 @@ Config is also searched in:
- The repo root and package directory
- `~/hate_crack`, `~/hate-crack`, or `~/.hate_crack`
**Note:** The `hcatPath` in `config.json` is for the hashcat binary location only (optional if hashcat is in PATH). Hate_crack assets (hashcat-utils, princeprocessor, omen) are loaded from the repository directory and bundled automatically by `make install`.
**Note:** The `hcatPath` in `config.json` is for the hashcat binary location only (optional if hashcat is in PATH). Hate_crack assets (hashcat-utils, princeprocessor, pcfg_cracker, omen) are loaded from the repository directory and bundled automatically by `make install`.
### Run as a script
The script uses a `uv` shebang. Make it executable and run:
@@ -527,6 +533,22 @@ The attack offers three generation modes:
2. **Wordlist** — derive basewords from a sample wordlist.
3. **Cracked passwords** — feed the plaintexts already recovered this session (`<hashfile>.out`) back to the model so it can infer the target organization's own password conventions (basewords, seasons, years, suffixes, leetspeak) and generate *new* candidates in the same style. This option is only listed once at least one hash has been cracked; the sample is capped by `ollamaMaxSampleLines` exactly like Wordlist mode.
#### PCFG Configuration
The PCFG Attack (option 20) and PRINCE-LING Attack (option 21) use the `pcfg_cracker` submodule. Configure them in `config.json`:
```json
{
"pcfgRuleset": "DEFAULT",
"pcfgMaxCandidates": 50000000,
"pcfgPrinceLingMaxCandidates": 10000000
}
```
- **`pcfgRuleset`** — Name of the trained grammar to use (default: `DEFAULT`), resolved to `pcfg_cracker/Rules/<name>/`. Train your own with pcfg_cracker's `trainer.py` and set this to the ruleset name.
- **`pcfgMaxCandidates`** — Maximum candidates `pcfg_guesser.py` emits for the PCFG attack (default: `50000000`).
- **`pcfgPrinceLingMaxCandidates`** — Maximum base words `prince_ling.py` writes into the cached PRINCE base wordlist (default: `10000000`).
### Notifications (menu option 82)
hate_crack can send Pushover push notifications when attacks complete and,
@@ -730,20 +752,22 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi
(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
(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
(90) Download rules from Hashmob.net
(91) Analyze Hashcat Rules
@@ -758,6 +782,10 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi
Select a task:
```
Option `94 — Hashview API` is only listed when `hashview_api_key` is set in `config.json`.
The YOLO, Middle, and Thorough Combinator attacks were previously at keys 10-12. They now live in the Combinator Attacks submenu (option 6) along with Combinator3 and CombinatorX.
-------------------------------------------------------------------
#### Quick Crack
Runs a dictionary attack against wordlists in your `hcatOptimizedWordlists` directory (falls back to `hcatWordlists` if not configured) and optionally applies rules. Multiple rules can be selected by comma-separated list, and chains can be created with the '+' symbol. Pressing Enter at the wordlist prompt uses the configured optimized wordlists directory as the default.
@@ -872,10 +900,12 @@ Uses hashcat's loopback mode to feed cracked passwords from the current session
#### LLM Attack
Uses a local Ollama instance to generate password candidates for a capture-the-flag scenario. Prompts for the fake company name, industry, and location, then sends these details to the configured LLM model to produce likely password candidates using industry terms and company name permutations. The generated candidates are fed into a hashcat wordlist+rules attack.
* Requires a running Ollama instance (default: `http://localhost:11434`)
* Configurable model and context window via `config.json` (see Ollama Configuration below)
* Prompts for target company name, industry, and location
* Requires a running Ollama instance (default: `http://localhost:11434`, override with `OLLAMA_HOST`) with the model already pulled — hate_crack does not auto-pull
* Candidate generation uses structured (JSON) output via Atomic Agents, so pick a model with good schema adherence (default: `qwen2.5:32b`)
* Configurable model, context window, request timeout, and sample size via `config.json` (see Ollama Configuration below)
* Prompts for target company name, industry, and location. The industry and location prompts are pre-filled with the local model's guesses about the named organization (editable, and clearly labelled as guesses rather than verified OSINT); disable with `ollamaAutoResearch: false`
* Alternatively derives basewords from a sample **wordlist**, or from the **cracked passwords** of the current session (`<hashfile>.out`) so the model mirrors the target organization's own password conventions and produces new candidates in that style (only offered once something has been cracked)
* A live spinner with an elapsed-seconds counter runs during generation, and requests are bounded by `ollamaTimeout` so a model stuck loading into VRAM reports a timeout instead of hanging
#### OMEN Attack
Uses the Ordered Markov ENumerator (OMEN) to train a statistical password model from a wordlist and generate password candidates. This attack learns patterns from known passwords and generates new candidates based on those patterns.
@@ -918,6 +948,14 @@ Generates password candidates using Markov chain statistical models. Similar to
* Markov table persists with hash file (filename.out.hcstat2) for fast subsequent runs
* Faster than OMEN for general-purpose brute forcing
#### N-gram Attack
Generates n-gram candidates from a corpus file using `ngramX.bin` from hashcat-utils and pipes them into hashcat.
* Prompts for a corpus file with tab completion, defaulting to the configured wordlist directory
* Prompts for an n-gram group size (default 3)
* Gzip-compressed corpus files are auto-detected and decompressed on the fly
* Useful when you have target-relevant prose (scraped site copy, leaked documents, internal wiki exports) rather than a password list
#### Permutation Attack
Generates all character permutations of each word in a targeted wordlist and pipes them to hashcat via `permute.bin` from hashcat-utils.
@@ -943,6 +981,23 @@ Generates all unique non-empty subset combinations from a short wordlist using `
* Aborts with a clear message if the wordlist exceeds 63 lines (hard limit)
* Candidates are piped directly to hashcat stdin
#### PCFG Attack
Uses [pcfg_cracker](https://github.com/lakiw/pcfg_cracker) to generate candidates from a Probabilistic Context-Free Grammar, piping `pcfg_guesser.py` output directly into hashcat's stdin mode. A PCFG models password *structure* (baseword + digits + symbol, capitalization habits, keyboard walks) with learned probabilities, so candidates come out roughly in descending likelihood order.
* Requires the `pcfg_cracker` submodule. Presence is checked at startup and reported non-fatally: if it is missing, the PCFG attacks are simply unavailable. Run `make` to fetch it.
* Uses the trained grammar named by `pcfgRuleset` in `config.json` (default `DEFAULT`), read from `pcfg_cracker/Rules/<name>/`
* Candidate count is capped by `pcfgMaxCandidates` (default 50,000,000)
* hate_crack does not wrap grammar training. To build a grammar from a target-specific password set, run pcfg_cracker's own `trainer.py` and point `pcfgRuleset` at the resulting ruleset name
#### PRINCE-LING Attack
Uses pcfg_cracker's `prince_ling.py` to derive an optimized PRINCE base wordlist from a trained grammar, then hands it to the existing PRINCE attack. PRINCE-LING picks base words the grammar says are actually productive, so the PRINCE combination space is far less wasteful than pointing PRINCE at a generic wordlist.
* Requires the `pcfg_cracker` submodule and a trained ruleset directory, same as the PCFG attack
* The generated wordlist is cached at `<hcatOptimizedWordlists>/pcfg_prince_ling_<ruleset>.txt` and reused across sessions
* Regenerates only when the ruleset directory is newer than the cached wordlist, so retraining a grammar invalidates the cache automatically
* Generation is written to a temporary file and atomically moved into place; a failed or interrupted run cleans up its partial file and leaves any existing cache intact
* Base wordlist size is capped by `pcfgPrinceLingMaxCandidates` (default 10,000,000)
#### Wordlist Tools (option 80)
A submenu of wordlist preprocessing utilities using hashcat-utils binaries. All tools read from and write to files on disk. All file and directory path prompts support tab completion.
+37 -13
View File
@@ -181,6 +181,7 @@ def quick_crack(ctx: Any) -> None:
print("Please enter a valid wordlist or wordlist directory.")
except ValueError:
print("Please enter a valid number.")
readline.set_completer(None)
selected_rules = _select_rules(ctx)
if selected_rules is None:
@@ -492,6 +493,7 @@ def _prompt_wordlist_paths(ctx, max_count: int) -> list[str]:
count += 1
else:
print(f"Not found: {resolved}")
readline.set_completer(None)
return collected
@@ -590,7 +592,7 @@ def ollama_attack(ctx: Any) -> None:
)
return
elif choice == "2":
path = _omen_pick_training_wordlist(ctx, title="LLM Sample Wordlists")
path = _pick_training_wordlist(ctx, title="LLM Sample Wordlists")
if not path:
return
ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "wordlist", path)
@@ -604,7 +606,7 @@ def ollama_attack(ctx: Any) -> None:
print("\t[!] Invalid selection.")
def _omen_pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"):
def _pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"):
"""Show wordlist picker. Returns path or None (user cancelled with 'q')."""
wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists)
# Print the grid once, outside the retry loop: a wordlists directory can
@@ -626,8 +628,10 @@ def _omen_pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"):
if sel.lower() == "q":
return None
if sel.lower() == "p":
path = input("\n\tPath to wordlist: ").strip()
return path if path else None
path = ctx.select_file_with_autocomplete(
"\tPath to wordlist (tab to autocomplete)"
)
return path.strip() if path else None
try:
idx = int(sel)
if 1 <= idx <= len(wordlist_files):
@@ -678,7 +682,7 @@ def omen_attack(ctx: Any) -> None:
print("\n\tNo valid OMEN model found. Training is required.")
if need_training:
training_file = _omen_pick_training_wordlist(ctx)
training_file = _pick_training_wordlist(ctx)
if not training_file:
return
if not ctx.hcatOmenTrain(training_file):
@@ -705,7 +709,7 @@ def _markov_pick_training_source(ctx: Any):
has_cracked = os.path.isfile(out_path) and os.path.getsize(out_path) > 0
wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists)
# Print the grid once, outside the retry loop — see _omen_pick_training_wordlist.
# Print the grid once, outside the retry loop — see _pick_training_wordlist.
entries = []
if has_cracked:
entries.append("0) Cracked passwords (current session)")
@@ -727,8 +731,10 @@ def _markov_pick_training_source(ctx: Any):
if sel == "0" and has_cracked:
return out_path
if sel.lower() == "p":
path = input("\n\tPath to training file: ").strip()
return path if path else None
path = ctx.select_file_with_autocomplete(
"\tPath to training file (tab to autocomplete)"
)
return path.strip() if path else None
try:
idx = int(sel)
if 1 <= idx <= len(wordlist_files):
@@ -805,7 +811,10 @@ def combipow_crack(ctx: Any) -> None:
_notify.prompt_notify_for_attack("Combipow")
wordlist = None
while wordlist is None:
path = input("\nEnter path to wordlist (max 63 lines recommended): ").strip()
path = ctx.select_file_with_autocomplete(
"Enter path to wordlist (max 63 lines recommended, tab to autocomplete)"
)
path = path.strip() if path else ""
if not path:
continue
if not os.path.isfile(path):
@@ -901,9 +910,11 @@ def generate_rules_crack(ctx: Any) -> None:
wordlist_choice = raw_choice
else:
print("[!] Wordlist not found. Please enter a valid path.")
readline.set_completer(None)
return
except ValueError:
print("Please enter a valid number.")
readline.set_completer(None)
ctx.hcatGenerateRules(
ctx.hcatHashType, ctx.hcatHashFile, rule_count, wordlist_choice
@@ -981,6 +992,7 @@ def permute_crack(ctx: Any) -> None:
print("[!] A directory was provided. Please enter a single wordlist file.")
continue
wordlist_path = raw
readline.set_completer(None)
ctx.hcatPermute(ctx.hcatHashType, ctx.hcatHashFile, wordlist_path)
@@ -1028,7 +1040,10 @@ def _rule_select_file(ctx: Any, prompt: str = "Rule file: ") -> str:
return None
_configure_readline(rule_completer)
return input(prompt).strip()
try:
return input(prompt).strip()
finally:
readline.set_completer(None)
def rule_cleanup_handler(ctx: Any) -> None:
@@ -1039,7 +1054,10 @@ def rule_cleanup_handler(ctx: Any) -> None:
if not infile or not os.path.isfile(infile):
print(f"[!] File not found: {infile}")
return
outfile = input("Output file path: ").strip()
outfile = ctx.select_file_with_autocomplete(
"Output file path (tab to autocomplete)"
)
outfile = outfile.strip() if outfile else ""
if not outfile:
print("[!] Output path required.")
return
@@ -1057,7 +1075,10 @@ def rule_optimize_handler(ctx: Any) -> None:
if not infile or not os.path.isfile(infile):
print(f"[!] File not found: {infile}")
return
outfile = input("Output file path: ").strip()
outfile = ctx.select_file_with_autocomplete(
"Output file path (tab to autocomplete)"
)
outfile = outfile.strip() if outfile else ""
if not outfile:
print("[!] Output path required.")
return
@@ -1077,7 +1098,10 @@ def rule_cleanup_and_optimize_handler(ctx: Any) -> None:
if not infile or not os.path.isfile(infile):
print(f"[!] File not found: {infile}")
return
outfile = input("Output file path: ").strip()
outfile = ctx.select_file_with_autocomplete(
"Output file path (tab to autocomplete)"
)
outfile = outfile.strip() if outfile else ""
if not outfile:
print("[!] Output path required.")
return
+42 -28
View File
@@ -1238,7 +1238,12 @@ def select_file_with_autocomplete(
full_prompt += f" (default: {default})"
full_prompt += ": "
result = input(full_prompt).strip()
try:
result = input(full_prompt).strip()
finally:
# Drop the path completer so later plain prompts (numeric menus, y/n)
# don't inherit stale file-path tab completion.
readline.set_completer(None)
if not result and base_dir:
result = base_dir
@@ -4438,15 +4443,18 @@ def pipal():
pipalFile.write(clearTextPass)
pipalFile.close()
pipalProcess = subprocess.Popen(
"{pipal_path} {pipal_file} -t {pipal_count} --output {pipal_out}".format(
pipal_path=pipalPath,
pipal_file=hcatHashFilePipal + ".passwords",
pipal_out=hcatHashFilePipal + ".pipal",
pipal_count=pipal_count,
),
shell=True,
)
# List-form Popen (no shell=True) so paths/filenames containing
# shell metacharacters can't be interpreted as commands. shlex.split
# on pipalPath still allows an interpreter prefix (e.g. "ruby
# /opt/pipal/pipal.rb") to be configured.
pipal_cmd = shlex.split(pipalPath) + [
hcatHashFilePipal + ".passwords",
"-t",
str(pipal_count),
"--output",
hcatHashFilePipal + ".pipal",
]
pipalProcess = subprocess.Popen(pipal_cmd)
try:
pipalProcess.wait()
except KeyboardInterrupt:
@@ -4469,25 +4477,31 @@ def pipal():
print(pipalfile.read())
print("\n--- Pipal Output End ---\n")
with open(hcatHashFilePipal + ".pipal") as pipalfile:
pipal_content = pipalfile.readlines()
raw_pipal = "\n".join(pipal_content)
raw_pipal = re.sub("\n+", "\n", raw_pipal)
raw_regex = r"Top [0-9]+ base words\n"
for word in range(pipal_count):
raw_regex += r"(\S+).*\n"
basewords_re = re.compile(raw_regex)
results = re.search(basewords_re, raw_pipal)
pipal_content = pipalfile.read()
# Parse the "Top N base words" section line by line rather than
# with one rigid regex. The old approach required *exactly*
# pipal_count baseword lines, so any cracked set with fewer
# unique base words than pipal_count (the common case on small
# cracks) matched nothing and returned []. Collect up to
# pipal_count base words and stop at the end of the section.
top_basewords = []
if results:
if results.lastindex is not None:
for i in range(1, results.lastindex + 1):
if i is not None:
top_basewords.append(results.group(i))
else:
pass
return top_basewords
else:
return []
in_section = False
for line in pipal_content.splitlines():
if re.match(r"\s*Top\s+[0-9]+\s+base words", line):
in_section = True
continue
if in_section:
if not line.strip():
# blank line terminates the base words section
break
# Capture the base word (first token); tolerate both
# "word = 5 (5%)" and "word 5" separators.
match = re.match(r"\s*(\S+)", line)
if match:
top_basewords.append(match.group(1))
if len(top_basewords) >= pipal_count:
break
return top_basewords
else:
print("No hashes were cracked :(")
return []
+7 -11
View File
@@ -55,18 +55,9 @@ stages = ["pre-push"]
pass_filenames = false
always_run = true
[[repos.hooks]]
id = "audit-docs"
name = "audit-docs"
entry = "bash .claude/audit-docs.sh HEAD"
language = "system"
stages = ["post-commit"]
pass_filenames = false
always_run = true
# General hygiene hooks (mirrors hashview's .pre-commit-config.yaml). These run
# at the pre-commit stage, so `prek install` must include `--hook-type pre-commit`
# (see CLAUDE.md). Auto-fixers modify files in place; re-stage and commit again.
# at the pre-commit stage, so `prek install` must include `--hook-type pre-commit`.
# Auto-fixers modify files in place; re-stage and commit again.
[[repos]]
repo = "https://github.com/pre-commit/pre-commit-hooks"
rev = "v5.0.0"
@@ -86,3 +77,8 @@ id = "check-merge-conflict"
[[repos.hooks]]
id = "check-added-large-files"
args = ["--maxkb=1024"]
# Blocks committing PEM/OpenSSH private keys. The repo had no secret-scanning
# gate at all before this; bandit only covers hate_crack/.
[[repos.hooks]]
id = "detect-private-key"
+2 -2
View File
@@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools>=69", "setuptools-scm>=8"]
requires = ["setuptools>=83.0.0", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"
[project]
@@ -10,7 +10,7 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"requests>=2.34.2",
"beautifulsoup4>=4.12.0",
"beautifulsoup4>=4.15.0",
"openpyxl>=3.0.0",
"packaging>=26.2",
"simple-term-menu==1.6.6",
+7 -7
View File
@@ -532,7 +532,7 @@ class TestOllamaAttack:
class TestOmenPickTrainingWordlistReprompt:
"""_omen_pick_training_wordlist re-prompts on invalid input instead of aborting."""
"""_pick_training_wordlist re-prompts on invalid input instead of aborting."""
def _make_ctx(self, wordlist_files=None):
ctx = MagicMock()
@@ -542,29 +542,29 @@ class TestOmenPickTrainingWordlistReprompt:
return ctx
def test_invalid_input_reprompts_then_valid_pick(self) -> None:
from hate_crack.attacks import _omen_pick_training_wordlist
from hate_crack.attacks import _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)
result = _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
from hate_crack.attacks import _pick_training_wordlist
ctx = self._make_ctx(["rockyou.txt"])
with patch("builtins.input", return_value="q"):
result = _omen_pick_training_wordlist(ctx)
result = _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
from hate_crack.attacks import _pick_training_wordlist
ctx = self._make_ctx(["rockyou.txt"])
with patch("builtins.input", side_effect=["99", "abc", "q"]):
result = _omen_pick_training_wordlist(ctx)
result = _pick_training_wordlist(ctx)
assert result is None
+24 -7
View File
@@ -66,7 +66,8 @@ class TestCombipowCrack:
ctx = _make_ctx()
wl = tmp_path / "words.txt"
wl.write_text("correct\nhorse\nbattery\n")
with patch("builtins.input", side_effect=[str(wl), ""]):
ctx.select_file_with_autocomplete.side_effect = [str(wl)]
with patch("builtins.input", side_effect=[""]):
attacks.combipow_crack(ctx)
ctx.hcatCombipow.assert_called_once()
call_args = ctx.hcatCombipow.call_args
@@ -75,12 +76,23 @@ class TestCombipowCrack:
)
assert use_space is True
def test_wordlist_path_uses_autocomplete_not_input(self, tmp_path):
attacks = _load_attacks()
ctx = _make_ctx()
wl = tmp_path / "words.txt"
wl.write_text("correct\nhorse\n")
ctx.select_file_with_autocomplete.side_effect = [str(wl)]
with patch("builtins.input", side_effect=[""]):
attacks.combipow_crack(ctx)
ctx.select_file_with_autocomplete.assert_called_once()
def test_calls_hcatCombipow_without_space_sep(self, tmp_path):
attacks = _load_attacks()
ctx = _make_ctx()
wl = tmp_path / "words.txt"
wl.write_text("correct\nhorse\nbattery\n")
with patch("builtins.input", side_effect=[str(wl), "n"]):
ctx.select_file_with_autocomplete.side_effect = [str(wl)]
with patch("builtins.input", side_effect=["n"]):
attacks.combipow_crack(ctx)
ctx.hcatCombipow.assert_called_once()
call_args = ctx.hcatCombipow.call_args
@@ -94,7 +106,8 @@ class TestCombipowCrack:
ctx = _make_ctx()
wl = tmp_path / "words.txt"
wl.write_text("\n".join(f"word{i}" for i in range(64)) + "\n")
with patch("builtins.input", return_value=str(wl)):
ctx.select_file_with_autocomplete.side_effect = [str(wl)]
with patch("builtins.input", side_effect=[]):
attacks.combipow_crack(ctx)
ctx.hcatCombipow.assert_not_called()
@@ -103,7 +116,8 @@ class TestCombipowCrack:
ctx = _make_ctx()
wl = tmp_path / "words.txt"
wl.write_text("\n".join(f"word{i}" for i in range(63)) + "\n")
with patch("builtins.input", side_effect=[str(wl), ""]):
ctx.select_file_with_autocomplete.side_effect = [str(wl)]
with patch("builtins.input", side_effect=[""]):
attacks.combipow_crack(ctx)
ctx.hcatCombipow.assert_called_once()
@@ -112,7 +126,8 @@ class TestCombipowCrack:
ctx = _make_ctx()
wl = tmp_path / "words.txt"
wl.write_text("test\n")
with patch("builtins.input", side_effect=["/nonexistent.txt", str(wl), ""]):
ctx.select_file_with_autocomplete.side_effect = ["/nonexistent.txt", str(wl)]
with patch("builtins.input", side_effect=[""]):
attacks.combipow_crack(ctx)
ctx.hcatCombipow.assert_called_once()
@@ -121,7 +136,8 @@ class TestCombipowCrack:
ctx = _make_ctx(hash_type="3200", hash_file="/tmp/bcrypt.txt")
wl = tmp_path / "words.txt"
wl.write_text("word1\nword2\n")
with patch("builtins.input", side_effect=[str(wl), ""]):
ctx.select_file_with_autocomplete.side_effect = [str(wl)]
with patch("builtins.input", side_effect=[""]):
attacks.combipow_crack(ctx)
ctx.hcatCombipow.assert_called_once()
args = ctx.hcatCombipow.call_args[0]
@@ -134,7 +150,8 @@ class TestCombipowCrack:
ctx = _make_ctx()
wl = tmp_path / "words.txt"
wl.write_text("\n".join(f"word{i}" for i in range(31)) + "\n")
with patch("builtins.input", side_effect=[str(wl), ""]):
ctx.select_file_with_autocomplete.side_effect = [str(wl)]
with patch("builtins.input", side_effect=[""]):
attacks.combipow_crack(ctx)
captured = capsys.readouterr()
assert "large" in captured.out.lower() or "warning" in captured.out.lower()
+80
View File
@@ -0,0 +1,80 @@
"""Regression tests: custom-path prompts must offer tab autocomplete.
Previously the "p. Enter a custom path" branches (OMEN, Markov) and the
combipow wordlist prompt used a bare ``input()`` with no readline completer,
so TAB did nothing. They now route through ``select_file_with_autocomplete``.
Also covers the canonical selector dropping its completer afterward so later
plain prompts don't inherit stale file-path completion.
"""
import os
import readline
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
os.environ["HATE_CRACK_SKIP_INIT"] = "1"
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def _load_attacks():
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
import hate_crack.attacks as attacks # noqa: PLC0415
return attacks
def _make_ctx():
ctx = MagicMock()
ctx.hcatHashType = "1000"
ctx.hcatHashFile = "/tmp/hashes.txt"
ctx.hcatWordlists = "/tmp/wordlists"
ctx.list_wordlist_files.return_value = []
return ctx
class TestOmenCustomPath:
def test_custom_path_uses_autocomplete(self):
attacks = _load_attacks()
ctx = _make_ctx()
ctx.select_file_with_autocomplete.return_value = "/data/custom.txt"
with patch("builtins.input", side_effect=["p"]):
result = attacks._pick_training_wordlist(ctx)
ctx.select_file_with_autocomplete.assert_called_once()
assert result == "/data/custom.txt"
def test_custom_path_blank_returns_none(self):
attacks = _load_attacks()
ctx = _make_ctx()
ctx.select_file_with_autocomplete.return_value = None
with patch("builtins.input", side_effect=["p"]):
result = attacks._pick_training_wordlist(ctx)
assert result is None
class TestMarkovCustomPath:
def test_custom_path_uses_autocomplete(self):
attacks = _load_attacks()
ctx = _make_ctx()
ctx.select_file_with_autocomplete.return_value = "/data/train.txt"
# out file absent -> "0" option not offered
with patch("builtins.input", side_effect=["p"]):
result = attacks._markov_pick_training_source(ctx)
ctx.select_file_with_autocomplete.assert_called_once()
assert result == "/data/train.txt"
class TestSelectorResetsCompleter:
def test_completer_reset_after_selection(self):
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
import hate_crack.main as m # noqa: PLC0415
sentinel = object()
readline.set_completer(lambda t, s: None)
with patch("builtins.input", return_value="/some/path"):
m.select_file_with_autocomplete("Pick a file")
assert readline.get_completer() is None
del sentinel
+10
View File
@@ -17,6 +17,15 @@ class TestHcatPCFG:
hash_file = str(tmp_path / "hashes.txt")
Path(hash_file).write_text("dummy")
# hcatPCFG resolves pcfg_guesser.py under the module global hate_path
# and bails early if it's missing. Pin hate_path to a tmp dir with a
# stub script so the test is hermetic — independent of the real
# pcfg_cracker submodule being checked out and of any hate_path value
# leaked by an earlier test (hate_crack.main is shared session-wide).
pcfg_dir = tmp_path / "pcfg_cracker"
pcfg_dir.mkdir()
(pcfg_dir / "pcfg_guesser.py").write_text("# stub")
captured_calls = []
class FakeProc:
@@ -27,6 +36,7 @@ class TestHcatPCFG:
with patch("hate_crack.main.subprocess.Popen", side_effect=FakeProc), \
patch("hate_crack.main._run_hcat_cmd") as mock_run, \
patch.object(main_module, "hate_path", str(tmp_path)), \
patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
+16 -10
View File
@@ -10,14 +10,18 @@ import pytest
class TestMarkovE2E:
"""End-to-end tests for complete markov attack workflow."""
def test_markov_training_plain_text(self, tmp_path: Path) -> None:
def test_markov_training_plain_text(self, tmp_path: Path, monkeypatch) -> None:
"""Test markov training with plain text wordlist."""
from hate_crack import main
# Setup paths
main.hate_path = Path(__file__).resolve().parents[1]
main.hcatHcstat2genBin = "hcstat2gen.bin"
bin_path = main.hate_path / "hashcat-utils" / "bin" / "hcstat2gen.bin"
# Setup paths. Use monkeypatch so these module globals are restored
# after the test — hate_crack.main is imported once and shared across
# the whole session, so a raw assignment here would leak into every
# later test (e.g. test_main_pcfg, which reads the ambient hate_path).
repo_root = Path(__file__).resolve().parents[1]
monkeypatch.setattr(main, "hate_path", repo_root)
monkeypatch.setattr(main, "hcatHcstat2genBin", "hcstat2gen.bin")
bin_path = repo_root / "hashcat-utils" / "bin" / "hcstat2gen.bin"
if not bin_path.is_file():
pytest.skip(f"hcstat2gen.bin not compiled: {bin_path}")
@@ -39,14 +43,16 @@ class TestMarkovE2E:
assert hcstat2_path.exists(), ".hcstat2 file should be created"
assert hcstat2_path.stat().st_size > 0, ".hcstat2 file should not be empty"
def test_markov_training_gzipped(self, tmp_path: Path) -> None:
def test_markov_training_gzipped(self, tmp_path: Path, monkeypatch) -> None:
"""Test markov training with gzipped wordlist."""
from hate_crack import main
# Setup paths
main.hate_path = Path(__file__).resolve().parents[1]
main.hcatHcstat2genBin = "hcstat2gen.bin"
bin_path = main.hate_path / "hashcat-utils" / "bin" / "hcstat2gen.bin"
# Setup paths (monkeypatch so these session-shared module globals are
# restored after the test — see test_markov_training_plain_text).
repo_root = Path(__file__).resolve().parents[1]
monkeypatch.setattr(main, "hate_path", repo_root)
monkeypatch.setattr(main, "hcatHcstat2genBin", "hcstat2gen.bin")
bin_path = repo_root / "hashcat-utils" / "bin" / "hcstat2gen.bin"
if not bin_path.is_file():
pytest.skip(f"hcstat2gen.bin not compiled: {bin_path}")
+3 -1
View File
@@ -355,12 +355,14 @@ class TestOmenAttackHandler:
def test_custom_path_for_training(self, tmp_path):
ctx = self._make_ctx(tmp_path, model_valid=False)
self._setup_rules_dir(tmp_path)
ctx.select_file_with_autocomplete.return_value = "/custom/wordlist.txt"
with patch("os.path.isfile", return_value=True), patch(
"builtins.input", side_effect=["p", "/custom/wordlist.txt", "", "0"]
"builtins.input", side_effect=["p", "", "0"]
):
from hate_crack.attacks import omen_attack
omen_attack(ctx)
ctx.select_file_with_autocomplete.assert_called_once()
ctx.hcatOmenTrain.assert_called_once_with("/custom/wordlist.txt")
def test_rules_passed_to_hcatOmen(self, tmp_path):
+211
View File
@@ -0,0 +1,211 @@
"""End-to-end tests for ``hate_crack.main.pipal``.
These are hermetic e2e tests: instead of requiring a real Ruby + pipal.rb
install, they drop a small fake ``pipal`` executable on disk that consumes the
same CLI (``<passwords> -t <N> --output <file>``) and emits a pipal-shaped
report. ``pipal()`` is then driven through its real code path writing the
``.passwords`` file (including ``$HEX[...]`` decoding), spawning the subprocess
via ``subprocess.Popen(..., shell=True)``, and parsing the ``Top N base words``
section back out.
A real-tool variant is gated behind ``HATE_CRACK_PIPAL_REAL=1`` +
``HATE_CRACK_PIPAL_PATH`` for anyone who wants to run against genuine pipal.rb.
"""
import os
import stat
import sys
import textwrap
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def _get_main_module():
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
os.environ["HATE_CRACK_SKIP_INIT"] = "1"
import hate_crack.main as m # noqa: PLC0415
return m
# A fake pipal that mimics the real tool closely enough for the parser:
# base word = password lowercased with trailing non-alphabetic characters
# stripped, ranked by descending frequency, and only the top -t entries are
# emitted under the "Top N base words" header.
_FAKE_PIPAL = textwrap.dedent(
"""\
#!/usr/bin/env python3
import re
import sys
args = sys.argv[1:]
pw_file = args[0]
top = 10
out = None
i = 1
while i < len(args):
if args[i] in ("-t", "--top"):
top = int(args[i + 1]); i += 2
elif args[i] in ("-o", "--output"):
out = args[i + 1]; i += 2
elif args[i] == "--output":
out = args[i + 1]; i += 2
else:
i += 1
counts = {}
with open(pw_file, encoding="utf-8", errors="replace") as fh:
for line in fh:
pw = line.rstrip("\\n")
if not pw:
continue
base = re.sub(r"[^a-zA-Z]+$", "", pw).lower()
if not base:
continue
counts[base] = counts.get(base, 0) + 1
ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
total = sum(counts.values())
lines = []
lines.append("Basic Results")
lines.append("")
lines.append("Total entries = %d" % total)
lines.append("")
lines.append("Top %d base words" % top)
for word, c in ranked[:top]:
pct = 100.0 * c / total if total else 0.0
lines.append("%s = %d (%.2f%%)" % (word, c, pct))
lines.append("")
with open(out, "w", encoding="utf-8") as fh:
fh.write("\\n".join(lines) + "\\n")
"""
)
def _install_fake_pipal(tmp_path: Path) -> Path:
fake = tmp_path / "pipal"
fake.write_text(_FAKE_PIPAL)
fake.chmod(fake.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return fake
def _run_pipal(monkeypatch, m, tmp_path, cracked_lines, pipal_count):
"""Wire up main-module globals and run the real ``pipal()``."""
fake = _install_fake_pipal(tmp_path)
hashfile = tmp_path / "hashes.txt"
hashfile.write_text("dummy\n")
(tmp_path / "hashes.txt.out").write_text("".join(cracked_lines))
monkeypatch.setattr(m, "pipalPath", str(fake))
monkeypatch.setattr(m, "pipal_count", pipal_count)
# hcatHashFile / hcatHashType only exist after main() runs; create them.
monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False)
monkeypatch.setattr(m, "hcatHashType", "0", raising=False) # not NTLM
return m.pipal(), hashfile
class TestPipalE2E:
def test_returns_top_basewords(self, monkeypatch, tmp_path, capsys):
m = _get_main_module()
cracked = [
"hash1:password1\n",
"hash2:password2\n",
"hash3:Password!\n",
"hash4:summer2021\n",
"hash5:summer99\n",
"hash6:winter1\n",
]
result, hashfile = _run_pipal(monkeypatch, m, tmp_path, cracked, pipal_count=3)
assert result == ["password", "summer", "winter"]
# pipal report and intermediate passwords file were produced
assert (Path(str(hashfile) + ".pipal")).is_file()
assert (Path(str(hashfile) + ".passwords")).is_file()
def test_passwords_file_decodes_hex(self, monkeypatch, tmp_path):
m = _get_main_module()
# 70617373776f7264 == "password"
cracked = [
"hash1:$HEX[70617373776f7264]\n",
"hash2:hunter2\n",
]
_run_pipal(monkeypatch, m, tmp_path, cracked, pipal_count=2)
pw_path = tmp_path / "hashes.txt.passwords"
contents = pw_path.read_text(encoding="utf-8").splitlines()
assert "password" in contents # $HEX decoded, not written literally
assert "hunter2" in contents
assert not any(line.startswith("$HEX[") for line in contents)
def test_no_cracked_output_returns_empty(self, monkeypatch, tmp_path):
m = _get_main_module()
fake = _install_fake_pipal(tmp_path)
hashfile = tmp_path / "hashes.txt"
hashfile.write_text("dummy\n") # note: no .out file created
monkeypatch.setattr(m, "pipalPath", str(fake))
monkeypatch.setattr(m, "pipal_count", 3)
monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False)
monkeypatch.setattr(m, "hcatHashType", "0", raising=False)
assert m.pipal() == []
def test_missing_pipal_path_returns_none(self, monkeypatch, tmp_path):
m = _get_main_module()
hashfile = tmp_path / "hashes.txt"
hashfile.write_text("dummy\n")
(tmp_path / "hashes.txt.out").write_text("hash1:password1\n")
monkeypatch.setattr(m, "pipalPath", str(tmp_path / "does-not-exist"))
monkeypatch.setattr(m, "pipal_count", 3)
monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False)
monkeypatch.setattr(m, "hcatHashType", "0", raising=False)
assert m.pipal() is None
def test_handles_shell_metacharacters_in_path(self, monkeypatch, tmp_path):
"""The subprocess call must not use a shell (no command injection).
A hash-file path containing shell metacharacters would, under the old
``shell=True`` string command, either break or execute the injected
fragment. With list-form Popen it is handled as a literal path.
"""
m = _get_main_module()
weird_dir = tmp_path / "a b; touch INJECTED"
weird_dir.mkdir()
cracked = ["hash1:password1\n", "hash2:summer2021\n"]
fake = _install_fake_pipal(tmp_path)
hashfile = weird_dir / "hashes.txt"
hashfile.write_text("dummy\n")
(weird_dir / "hashes.txt.out").write_text("".join(cracked))
monkeypatch.setattr(m, "pipalPath", str(fake))
monkeypatch.setattr(m, "pipal_count", 3)
monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False)
monkeypatch.setattr(m, "hcatHashType", "0", raising=False)
result = m.pipal()
assert result == ["password", "summer"]
# the injected `touch INJECTED` must NOT have run
assert not (tmp_path / "INJECTED").exists()
assert not Path("INJECTED").exists()
def test_fewer_basewords_than_count(self, monkeypatch, tmp_path):
"""Real-world case: fewer unique base words than ``pipal_count``.
A small cracked set should still surface the base words it *does*
have, rather than silently returning nothing.
"""
m = _get_main_module()
cracked = [
"hash1:password1\n",
"hash2:summer2021\n",
"hash3:winter1\n",
]
# default pipal_count (10) is larger than the 3 available base words
result, _ = _run_pipal(monkeypatch, m, tmp_path, cracked, pipal_count=10)
assert result == ["password", "summer", "winter"]
+28 -14
View File
@@ -26,7 +26,8 @@ class TestRuleCleanupHandler:
infile = tmp_path / "test.rule"
infile.write_text("l\nu\n")
outfile = tmp_path / "clean.rule"
with patch("builtins.input", side_effect=[str(infile), str(outfile)]):
ctx.select_file_with_autocomplete.return_value = str(outfile)
with patch("builtins.input", side_effect=[str(infile)]):
rule_cleanup_handler(ctx)
ctx.rules_cleanup.assert_called_once_with(str(infile), str(outfile))
@@ -40,7 +41,8 @@ class TestRuleCleanupHandler:
ctx = _make_ctx()
infile = tmp_path / "test.rule"
infile.write_text("l\n")
with patch("builtins.input", side_effect=[str(infile), ""]):
ctx.select_file_with_autocomplete.return_value = ""
with patch("builtins.input", side_effect=[str(infile)]):
rule_cleanup_handler(ctx)
ctx.rules_cleanup.assert_not_called()
@@ -49,7 +51,8 @@ class TestRuleCleanupHandler:
infile = tmp_path / "test.rule"
infile.write_text("l\n")
outfile = tmp_path / "clean.rule"
with patch("builtins.input", side_effect=[str(infile), str(outfile)]):
ctx.select_file_with_autocomplete.return_value = str(outfile)
with patch("builtins.input", side_effect=[str(infile)]):
rule_cleanup_handler(ctx)
assert "[+] Done." in capsys.readouterr().out
@@ -59,7 +62,8 @@ class TestRuleCleanupHandler:
infile = tmp_path / "test.rule"
infile.write_text("l\n")
outfile = tmp_path / "clean.rule"
with patch("builtins.input", side_effect=[str(infile), str(outfile)]):
ctx.select_file_with_autocomplete.return_value = str(outfile)
with patch("builtins.input", side_effect=[str(infile)]):
rule_cleanup_handler(ctx)
assert "[!] Cleanup failed." in capsys.readouterr().out
@@ -70,7 +74,8 @@ class TestRuleOptimizeHandler:
infile = tmp_path / "test.rule"
infile.write_text("l\nu\n")
outfile = tmp_path / "optimized.rule"
with patch("builtins.input", side_effect=[str(infile), str(outfile)]):
ctx.select_file_with_autocomplete.return_value = str(outfile)
with patch("builtins.input", side_effect=[str(infile)]):
rule_optimize_handler(ctx)
ctx.rules_optimize.assert_called_once_with(str(infile), str(outfile))
@@ -84,7 +89,8 @@ class TestRuleOptimizeHandler:
ctx = _make_ctx()
infile = tmp_path / "test.rule"
infile.write_text("l\n")
with patch("builtins.input", side_effect=[str(infile), ""]):
ctx.select_file_with_autocomplete.return_value = ""
with patch("builtins.input", side_effect=[str(infile)]):
rule_optimize_handler(ctx)
ctx.rules_optimize.assert_not_called()
@@ -93,7 +99,8 @@ class TestRuleOptimizeHandler:
infile = tmp_path / "test.rule"
infile.write_text("l\n")
outfile = tmp_path / "optimized.rule"
with patch("builtins.input", side_effect=[str(infile), str(outfile)]):
ctx.select_file_with_autocomplete.return_value = str(outfile)
with patch("builtins.input", side_effect=[str(infile)]):
rule_optimize_handler(ctx)
assert "[+] Done." in capsys.readouterr().out
@@ -103,7 +110,8 @@ class TestRuleOptimizeHandler:
infile = tmp_path / "test.rule"
infile.write_text("l\n")
outfile = tmp_path / "optimized.rule"
with patch("builtins.input", side_effect=[str(infile), str(outfile)]):
ctx.select_file_with_autocomplete.return_value = str(outfile)
with patch("builtins.input", side_effect=[str(infile)]):
rule_optimize_handler(ctx)
assert "[!] Optimize failed." in capsys.readouterr().out
@@ -114,7 +122,8 @@ class TestRuleCleanupAndOptimize:
infile = tmp_path / "test.rule"
infile.write_text("l\nu\n")
outfile = tmp_path / "final.rule"
with patch("builtins.input", side_effect=[str(infile), str(outfile)]):
ctx.select_file_with_autocomplete.return_value = str(outfile)
with patch("builtins.input", side_effect=[str(infile)]):
rule_cleanup_and_optimize_handler(ctx)
ctx.rules_cleanup.assert_called_once()
ctx.rules_optimize.assert_called_once()
@@ -125,7 +134,8 @@ class TestRuleCleanupAndOptimize:
infile = tmp_path / "test.rule"
infile.write_text("l\n")
outfile = tmp_path / "out.rule"
with patch("builtins.input", side_effect=[str(infile), str(outfile)]):
ctx.select_file_with_autocomplete.return_value = str(outfile)
with patch("builtins.input", side_effect=[str(infile)]):
rule_cleanup_and_optimize_handler(ctx)
ctx.rules_optimize.assert_not_called()
@@ -139,7 +149,8 @@ class TestRuleCleanupAndOptimize:
ctx = _make_ctx()
infile = tmp_path / "test.rule"
infile.write_text("l\n")
with patch("builtins.input", side_effect=[str(infile), ""]):
ctx.select_file_with_autocomplete.return_value = ""
with patch("builtins.input", side_effect=[str(infile)]):
rule_cleanup_and_optimize_handler(ctx)
ctx.rules_cleanup.assert_not_called()
@@ -155,7 +166,8 @@ class TestRuleCleanupAndOptimize:
infile = tmp_path / "test.rule"
infile.write_text("l\n")
outfile = tmp_path / "final.rule"
with patch("builtins.input", side_effect=[str(infile), str(outfile)]):
ctx.select_file_with_autocomplete.return_value = str(outfile)
with patch("builtins.input", side_effect=[str(infile)]):
rule_cleanup_and_optimize_handler(ctx)
assert captured_tmp, "rules_cleanup should have been called"
assert not os.path.exists(captured_tmp[0]), "temp file should be cleaned up"
@@ -172,7 +184,8 @@ class TestRuleCleanupAndOptimize:
infile = tmp_path / "test.rule"
infile.write_text("l\n")
outfile = tmp_path / "out.rule"
with patch("builtins.input", side_effect=[str(infile), str(outfile)]):
ctx.select_file_with_autocomplete.return_value = str(outfile)
with patch("builtins.input", side_effect=[str(infile)]):
rule_cleanup_and_optimize_handler(ctx)
if captured_tmp:
assert not os.path.exists(captured_tmp[0]), "temp file should be cleaned up"
@@ -182,7 +195,8 @@ class TestRuleCleanupAndOptimize:
infile = tmp_path / "test.rule"
infile.write_text("l\n")
outfile = tmp_path / "final.rule"
with patch("builtins.input", side_effect=[str(infile), str(outfile)]):
ctx.select_file_with_autocomplete.return_value = str(outfile)
with patch("builtins.input", side_effect=[str(infile)]):
rule_cleanup_and_optimize_handler(ctx)
assert "[+] Done." in capsys.readouterr().out