mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-07-28 22:51:14 -07:00
* fix: use sys.executable for PCFG/PRINCE-LING subprocesses
pcfg_guesser.py and prince_ling.py were launched via a bare "python3"
resolved from PATH, so they ran under whatever interpreter happened to
be first on PATH instead of the pinned 3.13 hate_crack itself runs
under. sys.executable always resolves to that interpreter, which also
makes the shutil.which("python3") presence check unnecessary.
Fixes #149
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: match pcfgRuleset default casing to on-disk Rules/Default
pcfgRuleset defaulted to "DEFAULT" but pcfg_cracker ships Rules/Default
(capitalized, not all-caps). Case-insensitive filesystems (macOS)
masked the mismatch; on case-sensitive filesystems (most Linux) both
PCFG-based attacks failed out of the box.
Fixes #148
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: keep PCFG/PRINCE-LING child stdin open to avoid silent truncation
hcatPCFG launched pcfg_guesser.py with no stdin=, so it inherited
hate_crack's own stdin. Whenever that stdin isn't a TTY (cron, CI,
detached runs, piped input), pcfg_guesser's keypress-listener thread
hit EOFError on its first input() call and the guesser shut itself
down after tens of thousands of candidates, silently ignoring
--limit/pcfgMaxCandidates. Passing stdin=subprocess.PIPE keeps the fd
open without writing to it, so input() blocks instead of raising.
prince_ling.py comes from the same upstream project and shares the
keypress thread, so hcatPrinceLing gets the same treatment.
Fixes #146
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* style: apply ruff format to hcatPCFG Popen call
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: catch OSError loading config.json.example defaults
The config.json.example defaults load only caught JSONDecodeError, so
a missing or unreadable file (e.g. a dangling symlink surviving a
git-archive tarball, docker COPY, or partial /opt/hate_crack install)
escaped as an uncaught FileNotFoundError instead of the "package
installation issue" message already written for exactly this case.
Extracted the load into _load_config_defaults() so the failure paths
are unit-testable, and added a dangling-symlink-specific message.
Fixes #155
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* style: apply ruff format to _load_config_defaults
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: replace brittle config.json.example test guards
test_packaged_example_is_symlink_to_root asserted symlink-ness, an
implementation detail that setuptools dereferences in every built
wheel/sdist — the test only ever passed in the source tree.
test_packaged_and_root_examples_have_identical_content compared the
same inode to itself there, making it a tautology. Neither would catch
real drift in a distributed artifact. Replaced both with an explicit
expected-key-set assertion (the substantive invariant #150 needed) and
a content-parity check that's only exercised outside the source tree.
Fixes #154
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: share config.json.example defaults between api.py and main.py
api.py's get_hcat_wordlists_dir/get_rules_dir/get_hcat_tuning_args/
get_hcat_potfile_path read config.json directly and fell back to their
own hardcoded, cwd-relative defaults for any key absent from disk.
main.py merges config.json.example into config_parser in memory (since
af46f59 stopped backfilling it to disk), so any key missing from a
user's config.json now resolves differently depending on which module
resolved it — e.g. rules_directory: <hate_path>/hashcat/rules in
main.py vs <cwd>/rules in api.py, silently creating an empty directory
under whatever directory the user launched from.
Added _load_config_defaults()/_load_merged_config() in api.py so both
modules perform the same config.json.example-then-config.json merge.
Fixes #153
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: revert inert hcatPrinceLing stdin=PIPE change
prince_ling.py never imports lib_guesser.cracking_session (the only
file with input()/a keypress thread) and never reads stdin, so there
was nothing for stdin=PIPE to fix there. Worse, subprocess.run(...,
stdin=subprocess.PIPE) with no input= argument closes the pipe
immediately via communicate(None), so the change was inert regardless.
The hcatPCFG side of #146 (Popen with stdin=PIPE, which correctly
keeps the write end open) is unaffected and remains the actual fix.
* fix: resolve pcfgRuleset case-insensitively against Rules/ on disk
Users with a config.json predating this stack's default-casing fix
have "DEFAULT" backfilled to disk (hate_crack used to write missing
keys to disk; see af46f59). For them, the new "Default" default in
config.json.example is never consulted, so Rules/DEFAULT still
doesn't resolve on case-sensitive filesystems and both PCFG attacks
stay broken. Resolve the configured ruleset name case-insensitively
against whatever casing actually exists under pcfg_cracker/Rules/, so
both a stale "DEFAULT" on disk and a user typo like "default" resolve
to the real Rules/Default directory.
* fix: resolve relative hcatPotfilePath against hate_path, not config dir
get_hcat_potfile_path resolved a relative hcatPotfilePath against the
config.json's own directory, while main.py resolves it against
hate_path unconditionally. These only agreed when config.json lived
at hate_path — they diverged for the ~/.hate_crack candidate root,
which is a first-class supported config location. This was the exact
divergence class #153 set out to eliminate, left inside the function
meant to fix it.
* fix: use resolved ruleset basename for prince_ling argv and cache path
hcatPrinceLing's ruleset existence check was fixed to resolve
pcfgRuleset case-insensitively against Rules/ on disk, but the
subprocess argv passed to prince_ling.py and the cache filename both
still used the raw, unresolved value. prince_ling.py does its own
(case-sensitive) Rules/ lookup internally, so a legacy "DEFAULT"
config would pass hate_crack's precheck and then fail inside
prince_ling.py itself with a confusing "generation failed" message.
Use the resolved basename in both places, matching what hcatPCFG
already does for its --rule argument.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1403e0190c
commit
8db889c8d5
+1
-1
@@ -32,7 +32,7 @@
|
||||
"ollamaAutoResearch": true,
|
||||
"omenTrainingList": "rockyou.txt",
|
||||
"omenMaxCandidates": 100000000,
|
||||
"pcfgRuleset": "DEFAULT",
|
||||
"pcfgRuleset": "Default",
|
||||
"pcfgMaxCandidates": 50000000,
|
||||
"pcfgPrinceLingMaxCandidates": 10000000,
|
||||
"check_for_updates": true,
|
||||
|
||||
+66
-50
@@ -491,79 +491,95 @@ class TransmissionSession:
|
||||
self.remove(entry["id"])
|
||||
|
||||
|
||||
def get_hcat_wordlists_dir():
|
||||
def _load_config_defaults():
|
||||
"""Load config.json.example, searching the same candidate order main.py
|
||||
uses: alongside the resolved config.json (if any), then this package's
|
||||
directory. Returns {} if no readable, well-formed example is found —
|
||||
callers fall back to their own hardcoded defaults in that case.
|
||||
"""
|
||||
candidates = []
|
||||
config_path = _resolve_config_path()
|
||||
if config_path:
|
||||
candidates.append(os.path.dirname(config_path))
|
||||
candidates.append(os.path.dirname(os.path.realpath(__file__)))
|
||||
for candidate_dir in candidates:
|
||||
example_path = os.path.join(candidate_dir, "config.json.example")
|
||||
if os.path.isfile(example_path):
|
||||
try:
|
||||
with open(example_path) as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
return {}
|
||||
|
||||
|
||||
def _load_merged_config():
|
||||
"""config.json.example defaults overlaid with config.json, mirroring
|
||||
the merge main.py performs at import time. Fixes #153: api.py's
|
||||
helpers previously read config.json directly and fell back to their
|
||||
own hardcoded (cwd-relative) defaults when a key was absent, silently
|
||||
diverging from main.py whenever a user's config.json predated a key.
|
||||
"""
|
||||
merged = _load_config_defaults()
|
||||
config_path = _resolve_config_path()
|
||||
if config_path:
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
path = config.get("hcatWordlists")
|
||||
if path:
|
||||
path = os.path.expanduser(path)
|
||||
if not os.path.isabs(path):
|
||||
path = os.path.normpath(os.path.join(_get_hate_path(), path))
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
except Exception:
|
||||
merged.update(json.load(f))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return merged
|
||||
|
||||
|
||||
def get_hcat_wordlists_dir():
|
||||
config = _load_merged_config()
|
||||
path = config.get("hcatWordlists")
|
||||
if path:
|
||||
path = os.path.expanduser(path)
|
||||
if not os.path.isabs(path):
|
||||
path = os.path.normpath(os.path.join(_get_hate_path(), path))
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
default = os.path.join(os.getcwd(), "wordlists")
|
||||
os.makedirs(default, exist_ok=True)
|
||||
return default
|
||||
|
||||
|
||||
def get_rules_dir():
|
||||
config_path = _resolve_config_path()
|
||||
if config_path:
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
path = config.get("rules_directory")
|
||||
if path:
|
||||
path = os.path.expanduser(path)
|
||||
if not os.path.isabs(path):
|
||||
path = os.path.normpath(os.path.join(_get_hate_path(), path))
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
except Exception:
|
||||
pass
|
||||
config = _load_merged_config()
|
||||
path = config.get("rules_directory")
|
||||
if path:
|
||||
path = os.path.expanduser(path)
|
||||
if not os.path.isabs(path):
|
||||
path = os.path.normpath(os.path.join(_get_hate_path(), path))
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
default = os.path.join(os.getcwd(), "rules")
|
||||
os.makedirs(default, exist_ok=True)
|
||||
return default
|
||||
|
||||
|
||||
def get_hcat_tuning_args():
|
||||
config_path = _resolve_config_path()
|
||||
if config_path:
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
tuning = config.get("hcatTuning")
|
||||
if tuning:
|
||||
import shlex
|
||||
config = _load_merged_config()
|
||||
tuning = config.get("hcatTuning")
|
||||
if tuning:
|
||||
import shlex
|
||||
|
||||
return shlex.split(tuning)
|
||||
except Exception:
|
||||
pass
|
||||
return shlex.split(tuning)
|
||||
return []
|
||||
|
||||
|
||||
def get_hcat_potfile_path():
|
||||
"""Return the resolved potfile path from config, or the default."""
|
||||
config_path = _resolve_config_path()
|
||||
if config_path:
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
if "hcatPotfilePath" in config:
|
||||
raw = (config["hcatPotfilePath"] or "").strip()
|
||||
if raw == "":
|
||||
return ""
|
||||
expanded = os.path.expanduser(raw)
|
||||
if not os.path.isabs(expanded):
|
||||
expanded = os.path.join(os.path.dirname(config_path), expanded)
|
||||
return expanded
|
||||
except Exception:
|
||||
pass
|
||||
config = _load_merged_config()
|
||||
if "hcatPotfilePath" in config:
|
||||
raw = (config["hcatPotfilePath"] or "").strip()
|
||||
if raw == "":
|
||||
return ""
|
||||
expanded = os.path.expanduser(raw)
|
||||
if not os.path.isabs(expanded):
|
||||
expanded = os.path.join(_get_hate_path(), expanded)
|
||||
return expanded
|
||||
return os.path.expanduser("~/.hashcat/hashcat.potfile")
|
||||
|
||||
|
||||
|
||||
+60
-14
@@ -242,18 +242,36 @@ except json.JSONDecodeError as e:
|
||||
print(" 2. Delete the file to regenerate from defaults")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _load_config_defaults(defaults_path):
|
||||
"""Load config.json.example, exiting with a clear diagnostic on
|
||||
malformed JSON or an unreadable/missing file (see #155 — a dangling
|
||||
symlink surfaces as FileNotFoundError, which used to escape uncaught).
|
||||
"""
|
||||
try:
|
||||
with open(defaults_path) as defaults:
|
||||
return json.load(defaults)
|
||||
except json.JSONDecodeError:
|
||||
print("\nError: config.json.example contains invalid JSON")
|
||||
print(f" File: {defaults_path}")
|
||||
print(" This is a package installation issue. Try reinstalling hate_crack.")
|
||||
sys.exit(1)
|
||||
except OSError:
|
||||
print("\nError: config.json.example could not be read")
|
||||
print(f" File: {defaults_path}")
|
||||
if os.path.islink(defaults_path) and not os.path.exists(defaults_path):
|
||||
print(
|
||||
" This is a dangling symlink: the link exists but its target is missing."
|
||||
)
|
||||
print(" This is a package installation issue. Try reinstalling hate_crack.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
config_dir = os.path.dirname(_config_path)
|
||||
defaults_path = os.path.join(config_dir, "config.json.example")
|
||||
if not os.path.isfile(defaults_path):
|
||||
defaults_path = os.path.join(_package_path, "config.json.example")
|
||||
try:
|
||||
with open(defaults_path) as defaults:
|
||||
default_config = json.load(defaults)
|
||||
except json.JSONDecodeError:
|
||||
print("\nError: config.json.example contains invalid JSON")
|
||||
print(f" File: {defaults_path}")
|
||||
print(" This is a package installation issue. Try reinstalling hate_crack.")
|
||||
sys.exit(1)
|
||||
default_config = _load_config_defaults(defaults_path)
|
||||
|
||||
for _key, _value in default_config.items():
|
||||
if _key not in config_parser:
|
||||
@@ -463,7 +481,7 @@ ollamaAutoResearch = bool(config_parser.get("ollamaAutoResearch", True))
|
||||
|
||||
omenTrainingList = config_parser.get("omenTrainingList", "rockyou.txt")
|
||||
omenMaxCandidates = int(config_parser.get("omenMaxCandidates", 100000000))
|
||||
pcfgRuleset = config_parser.get("pcfgRuleset", "DEFAULT")
|
||||
pcfgRuleset = config_parser.get("pcfgRuleset", "Default")
|
||||
pcfgMaxCandidates = int(config_parser.get("pcfgMaxCandidates", 50000000))
|
||||
pcfgPrinceLingMaxCandidates = int(
|
||||
config_parser.get("pcfgPrinceLingMaxCandidates", 10000000)
|
||||
@@ -2759,17 +2777,38 @@ def hcatPrince(hcatHashType, hcatHashFile, attack_name="PRINCE"):
|
||||
prince_proc.stdout.close()
|
||||
|
||||
|
||||
def _resolve_pcfg_ruleset_dir(pcfg_root, ruleset_name):
|
||||
"""Resolve ruleset_name against pcfg_root/Rules case-insensitively.
|
||||
|
||||
Older config.json files may have "DEFAULT" backfilled to disk from
|
||||
before the default changed to "Default" (see #148) — match whatever
|
||||
casing exists on disk rather than requiring an exact match.
|
||||
"""
|
||||
exact = os.path.join(pcfg_root, "Rules", ruleset_name)
|
||||
if os.path.isdir(exact):
|
||||
return exact
|
||||
rules_root = os.path.join(pcfg_root, "Rules")
|
||||
if os.path.isdir(rules_root):
|
||||
for entry in os.listdir(rules_root):
|
||||
if entry.lower() == ruleset_name.lower():
|
||||
return os.path.join(rules_root, entry)
|
||||
return exact
|
||||
|
||||
|
||||
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_root = os.path.join(hate_path, "pcfg_cracker")
|
||||
resolved_ruleset_dir = _resolve_pcfg_ruleset_dir(pcfg_root, pcfgRuleset)
|
||||
resolved_ruleset_name = os.path.basename(resolved_ruleset_dir)
|
||||
pcfg_cmd = [
|
||||
sys.executable,
|
||||
pcfg_guesser_script,
|
||||
"--rule",
|
||||
pcfgRuleset,
|
||||
resolved_ruleset_name,
|
||||
"--limit",
|
||||
str(pcfgMaxCandidates),
|
||||
]
|
||||
@@ -2787,7 +2826,9 @@ def hcatPCFG(hcatHashType, hcatHashFile):
|
||||
_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)
|
||||
pcfg_proc = subprocess.Popen(
|
||||
pcfg_cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE
|
||||
)
|
||||
_run_hcat_cmd(
|
||||
hashcat_cmd,
|
||||
attack_name="PCFG",
|
||||
@@ -2797,6 +2838,8 @@ def hcatPCFG(hcatHashType, hcatHashFile):
|
||||
)
|
||||
if pcfg_proc.stdout:
|
||||
pcfg_proc.stdout.close()
|
||||
if pcfg_proc.stdin:
|
||||
pcfg_proc.stdin.close()
|
||||
|
||||
|
||||
def hcatPrinceLing(hcatHashType, hcatHashFile):
|
||||
@@ -2807,13 +2850,14 @@ def hcatPrinceLing(hcatHashType, hcatHashFile):
|
||||
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)
|
||||
ruleset_dir = _resolve_pcfg_ruleset_dir(pcfg_root, 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
|
||||
resolved_ruleset_name = os.path.basename(ruleset_dir)
|
||||
|
||||
cache_dir = (
|
||||
hcatOptimizedWordlists
|
||||
@@ -2821,7 +2865,9 @@ def hcatPrinceLing(hcatHashType, hcatHashFile):
|
||||
else str(hcatOptimizedWordlists)
|
||||
)
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
cache_path = os.path.join(cache_dir, f"pcfg_prince_ling_{pcfgRuleset}.txt")
|
||||
cache_path = os.path.join(
|
||||
cache_dir, f"pcfg_prince_ling_{resolved_ruleset_name}.txt"
|
||||
)
|
||||
tmp_path = cache_path + ".tmp"
|
||||
|
||||
# Staleness check: regenerate iff ruleset dir mtime > cache mtime (strict)
|
||||
@@ -2838,7 +2884,7 @@ def hcatPrinceLing(hcatHashType, hcatHashFile):
|
||||
sys.executable,
|
||||
prince_ling_script,
|
||||
"--rule",
|
||||
pcfgRuleset,
|
||||
resolved_ruleset_name,
|
||||
"--output",
|
||||
tmp_path,
|
||||
"--size",
|
||||
|
||||
@@ -397,7 +397,8 @@ class TestGetHcatPotfilePath:
|
||||
config_data = {"hcatPotfilePath": "hashcat.potfile"}
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps(config_data))
|
||||
with patch("hate_crack.api._resolve_config_path", return_value=str(config_file)):
|
||||
with patch("hate_crack.api._resolve_config_path", return_value=str(config_file)), \
|
||||
patch("hate_crack.api._get_hate_path", return_value=str(tmp_path)):
|
||||
result = get_hcat_potfile_path()
|
||||
assert result == str(tmp_path / "hashcat.potfile")
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests for hate_crack.main's config.json.example defaults loader (#155)."""
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def main_module(hc_module):
|
||||
return hc_module._main
|
||||
|
||||
|
||||
def test_missing_defaults_file_exits_with_clear_message(main_module, tmp_path, capsys):
|
||||
missing_path = str(tmp_path / "config.json.example")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main_module._load_config_defaults(missing_path)
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "package installation issue" in captured.out
|
||||
assert missing_path in captured.out
|
||||
|
||||
|
||||
def test_dangling_symlink_defaults_file_names_the_cause(main_module, tmp_path, capsys):
|
||||
target = tmp_path / "does-not-exist.json"
|
||||
link_path = tmp_path / "config.json.example"
|
||||
os.symlink(target, link_path)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main_module._load_config_defaults(str(link_path))
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "dangling symlink" in captured.out.lower()
|
||||
|
||||
|
||||
def test_malformed_json_defaults_file_exits_with_clear_message(main_module, tmp_path, capsys):
|
||||
bad_path = tmp_path / "config.json.example"
|
||||
bad_path.write_text("{not valid json")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main_module._load_config_defaults(str(bad_path))
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "invalid JSON" in captured.out
|
||||
|
||||
|
||||
def test_valid_defaults_file_loads_normally(main_module, tmp_path):
|
||||
good_path = tmp_path / "config.json.example"
|
||||
good_path.write_text(json.dumps({"hcatBin": "hashcat"}))
|
||||
|
||||
result = main_module._load_config_defaults(str(good_path))
|
||||
|
||||
assert result == {"hcatBin": "hashcat"}
|
||||
@@ -5,18 +5,51 @@ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ROOT_EXAMPLE = os.path.join(REPO_ROOT, "config.json.example")
|
||||
PACKAGED_EXAMPLE = os.path.join(REPO_ROOT, "hate_crack", "config.json.example")
|
||||
|
||||
|
||||
def test_packaged_example_is_symlink_to_root():
|
||||
assert os.path.islink(PACKAGED_EXAMPLE), (
|
||||
"hate_crack/config.json.example must be a symlink to the root "
|
||||
"config.json.example so there is a single source of truth for defaults"
|
||||
)
|
||||
# Source of truth for config.json.example's key set. Update this set
|
||||
# whenever a config key is added or removed — it's what would have caught
|
||||
# the #150 drift (10 missing keys, 4 dead passgpt* keys shipped in a prior
|
||||
# release) regardless of whether the packaged copy is a symlink, a
|
||||
# dereferenced regular file (wheel/sdist builds), or a flattened copy
|
||||
# (git-archive tarball, docker COPY of hate_crack/ alone).
|
||||
EXPECTED_KEYS = {
|
||||
"hcatPath", "hcatBin", "hcatTuning", "hcatPotfilePath", "hcatDebugLogPath",
|
||||
"hcatWordlists", "hcatOptimizedWordlists", "rules_directory",
|
||||
"hcatDictionaryWordlist", "hcatCombinationWordlist", "hcatCombinator3Wordlist",
|
||||
"hcatCombinatorXWordlist", "hcatHybridlist", "hcatMiddleCombinatorMasks",
|
||||
"hcatMiddleBaseList", "hcatThoroughCombinatorMasks", "hcatThoroughBaseList",
|
||||
"hcatGoodMeasureBaseList", "hcatPrinceBaseList", "pipalPath", "pipal_count",
|
||||
"bandrelmaxruntime", "bandrel_common_basedwords", "hashview_url",
|
||||
"hashview_api_key", "hashmob_api_key", "ollamaModel", "ollamaNumCtx",
|
||||
"ollamaTimeout", "ollamaMaxSampleLines", "ollamaAutoResearch",
|
||||
"omenTrainingList", "omenMaxCandidates", "pcfgRuleset", "pcfgMaxCandidates",
|
||||
"pcfgPrinceLingMaxCandidates", "check_for_updates", "optimizedKernelAttacks",
|
||||
"notify_enabled", "notify_pushover_token", "notify_pushover_user",
|
||||
"notify_per_crack_enabled", "notify_attack_allowlist",
|
||||
"notify_suppress_in_orchestrators", "notify_max_cracks_per_burst",
|
||||
"notify_poll_interval_seconds",
|
||||
}
|
||||
|
||||
|
||||
def test_packaged_and_root_examples_have_identical_content():
|
||||
def test_root_example_has_expected_keys():
|
||||
with open(ROOT_EXAMPLE) as f:
|
||||
root_config = json.load(f)
|
||||
assert set(root_config.keys()) == EXPECTED_KEYS
|
||||
|
||||
|
||||
def test_packaged_example_matches_root_content():
|
||||
"""The invariant that matters is content parity, not symlink-ness.
|
||||
|
||||
In the source tree hate_crack/config.json.example is a symlink to the
|
||||
root copy (same inode) — comparing it to itself here is a no-op, and
|
||||
test_root_example_has_expected_keys is the substantive check for that
|
||||
environment. In any tree where the packaged copy is a distinct file
|
||||
(built wheel/sdist, git-archive tarball, docker COPY of hate_crack/
|
||||
alone) this comparison is the one that would actually catch drift.
|
||||
"""
|
||||
if os.path.realpath(PACKAGED_EXAMPLE) == os.path.realpath(ROOT_EXAMPLE):
|
||||
return
|
||||
with open(ROOT_EXAMPLE) as f:
|
||||
root_config = json.load(f)
|
||||
with open(PACKAGED_EXAMPLE) as f:
|
||||
packaged_config = json.load(f)
|
||||
|
||||
assert packaged_config == root_config
|
||||
|
||||
+100
-8
@@ -34,6 +34,8 @@ class TestHcatPCFG:
|
||||
captured_calls.append((args, kwargs))
|
||||
self.stdout = MagicMock()
|
||||
self.stdout.close = MagicMock()
|
||||
self.stdin = MagicMock()
|
||||
self.stdin.close = MagicMock()
|
||||
|
||||
with patch("hate_crack.main.subprocess.Popen", side_effect=FakeProc), \
|
||||
patch("hate_crack.main._run_hcat_cmd") as mock_run, \
|
||||
@@ -72,12 +74,45 @@ class TestHcatPCFG:
|
||||
assert kwargs["companion_procs"] is not None
|
||||
assert len(kwargs["companion_procs"]) == 1
|
||||
|
||||
def test_pcfg_child_stdin_stays_open(self, main_module, tmp_path):
|
||||
hash_file = str(tmp_path / "hashes.txt")
|
||||
Path(hash_file).write_text("dummy")
|
||||
|
||||
pcfg_dir = tmp_path / "pcfg_cracker"
|
||||
pcfg_dir.mkdir()
|
||||
(pcfg_dir / "pcfg_guesser.py").write_text("# stub")
|
||||
|
||||
captured_calls = []
|
||||
|
||||
class FakeProc:
|
||||
def __init__(self, *args, **kwargs):
|
||||
captured_calls.append((args, kwargs))
|
||||
self.stdout = MagicMock()
|
||||
self.stdout.close = MagicMock()
|
||||
self.stdin = MagicMock()
|
||||
self.stdin.close = MagicMock()
|
||||
|
||||
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", ""), \
|
||||
patch.object(main_module, "generate_session_id", return_value="test_session"):
|
||||
main_module.hcatPCFG("0", hash_file)
|
||||
|
||||
producer_args, producer_kwargs = captured_calls[0]
|
||||
assert producer_kwargs.get("stdin") is not None
|
||||
|
||||
fake_proc = mock_run.call_args.kwargs["companion_procs"][0]
|
||||
assert fake_proc.stdin.close.called
|
||||
|
||||
|
||||
class TestHcatPrinceLing:
|
||||
def _setup_pcfg_dirs(self, tmp_path, main_module, monkeypatch):
|
||||
"""Lay out fake pcfg_cracker/Rules/<ruleset>/ and optimized_wordlists/."""
|
||||
pcfg_root = tmp_path / "pcfg_cracker"
|
||||
rules_dir = pcfg_root / "Rules" / "DEFAULT"
|
||||
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
|
||||
@@ -91,7 +126,7 @@ class TestHcatPrinceLing:
|
||||
|
||||
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 = 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)
|
||||
@@ -118,7 +153,7 @@ class TestHcatPrinceLing:
|
||||
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"
|
||||
assert cmd[cmd.index("--rule") + 1] == "Default"
|
||||
# Uses --size, NOT --limit
|
||||
assert "--size" in cmd
|
||||
assert "--limit" not in cmd
|
||||
@@ -127,7 +162,7 @@ class TestHcatPrinceLing:
|
||||
|
||||
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 = opt_dir / "pcfg_prince_ling_Default.txt"
|
||||
cache.write_text("fresh")
|
||||
# Cache is newer than ruleset
|
||||
future = rules_dir.stat().st_mtime + 1000
|
||||
@@ -156,12 +191,12 @@ class TestHcatPrinceLing:
|
||||
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()
|
||||
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 = opt_dir / "pcfg_prince_ling_Default.txt"
|
||||
cache.write_text("fresh")
|
||||
future = rules_dir.stat().st_mtime + 1000
|
||||
os.utime(cache, (future, future))
|
||||
@@ -180,7 +215,7 @@ class TestHcatPrinceLing:
|
||||
|
||||
def test_uses_sys_executable(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 = opt_dir / "pcfg_prince_ling_Default.txt"
|
||||
cache.write_text("stale")
|
||||
old = (rules_dir.stat().st_mtime - 100)
|
||||
os.utime(cache, (old, old))
|
||||
@@ -201,3 +236,60 @@ class TestHcatPrinceLing:
|
||||
main_module.hcatPrinceLing("0", str(tmp_path / "hashes.txt"))
|
||||
|
||||
assert run_calls[0][0] == sys.executable
|
||||
|
||||
def test_resolves_ruleset_case_insensitively(self, main_module, tmp_path, monkeypatch):
|
||||
rules_dir, opt_dir = self._setup_pcfg_dirs(tmp_path, main_module, monkeypatch)
|
||||
# Cache file uses the resolved on-disk basename ("Default"), not the
|
||||
# raw (legacy, all-caps) config value.
|
||||
cache = opt_dir / "pcfg_prince_ling_Default.txt"
|
||||
cache.write_text("stale")
|
||||
old = (rules_dir.stat().st_mtime - 100)
|
||||
os.utime(cache, (old, old))
|
||||
|
||||
# Simulate a config.json predating the default-casing fix, where
|
||||
# "DEFAULT" was backfilled to disk instead of "Default".
|
||||
monkeypatch.setattr(main_module, "pcfgRuleset", "DEFAULT")
|
||||
|
||||
# Force case-sensitive isdir semantics for this test: on
|
||||
# case-insensitive-but-case-preserving filesystems (e.g. macOS
|
||||
# APFS), os.path.isdir(".../DEFAULT") would spuriously match the
|
||||
# real ".../Default" dir, masking the fallback path this test is
|
||||
# meant to exercise (and which is what actually runs on CI's
|
||||
# case-sensitive Linux filesystem).
|
||||
real_isdir = os.path.isdir
|
||||
|
||||
def case_sensitive_isdir(path):
|
||||
parent, name = os.path.split(path)
|
||||
try:
|
||||
return name in os.listdir(parent) and real_isdir(path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
run_calls = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
run_calls.append(cmd)
|
||||
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, \
|
||||
patch("hate_crack.main.os.path.isdir", side_effect=case_sensitive_isdir):
|
||||
main_module.hcatPrinceLing("0", str(tmp_path / "hashes.txt"))
|
||||
|
||||
# Should have found the on-disk "Default" dir and proceeded, not
|
||||
# printed "PCFG ruleset not found" and returned early.
|
||||
assert len(run_calls) == 1
|
||||
cmd = run_calls[0]
|
||||
# The subprocess argv and cache filename must both use the resolved
|
||||
# on-disk basename ("Default"), not the raw monkeypatched "DEFAULT"
|
||||
# value, since prince_ling.py does its own case-sensitive Rules/
|
||||
# lookup internally.
|
||||
assert "--rule" in cmd
|
||||
assert cmd[cmd.index("--rule") + 1] == "Default"
|
||||
assert cache.exists()
|
||||
assert mock_prince.called
|
||||
|
||||
+48
-4
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import importlib
|
||||
@@ -98,8 +99,28 @@ def test_get_hcat_wordlists_dir_from_config(tmp_path, monkeypatch):
|
||||
assert os.path.isdir(result)
|
||||
|
||||
|
||||
def test_get_hcat_wordlists_dir_fallback_cwd(tmp_path, monkeypatch):
|
||||
def test_get_hcat_wordlists_dir_no_config_uses_example_default(tmp_path, monkeypatch):
|
||||
"""Regression test for #153: with no config.json on disk, api.py must
|
||||
resolve the same default main.py does (config.json.example's
|
||||
hcatWordlists, relative to hate_path) rather than a hardcoded
|
||||
cwd-relative fallback that only main.py knew to avoid.
|
||||
"""
|
||||
monkeypatch.setattr(api, "_resolve_config_path", lambda: None)
|
||||
monkeypatch.setattr(api, "_get_hate_path", lambda: str(tmp_path))
|
||||
|
||||
result = api.get_hcat_wordlists_dir()
|
||||
|
||||
assert result == str(tmp_path / "wordlists")
|
||||
assert os.path.isdir(result)
|
||||
|
||||
|
||||
def test_get_hcat_wordlists_dir_true_fallback_when_no_example(tmp_path, monkeypatch):
|
||||
"""Last-resort cwd fallback still applies if config.json.example itself
|
||||
can't be found or read (e.g. hate_crack.main's equivalent loader exits
|
||||
at import time on an unreadable example; api.py's degrades gracefully
|
||||
instead)."""
|
||||
monkeypatch.setattr(api, "_resolve_config_path", lambda: None)
|
||||
monkeypatch.setattr(api, "_load_config_defaults", lambda: {})
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
result = api.get_hcat_wordlists_dir()
|
||||
@@ -120,16 +141,39 @@ def test_get_rules_dir_from_config(tmp_path, monkeypatch):
|
||||
assert os.path.isdir(result)
|
||||
|
||||
|
||||
def test_get_rules_dir_fallback_cwd(tmp_path, monkeypatch):
|
||||
def test_get_rules_dir_no_config_uses_example_default(tmp_path, monkeypatch):
|
||||
"""Regression test for #153: api.py's rules_directory default must match
|
||||
main.py's ('./hashcat/rules' from config.json.example), not the
|
||||
hardcoded '<cwd>/rules' this helper used before the fix.
|
||||
"""
|
||||
monkeypatch.setattr(api, "_resolve_config_path", lambda: None)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(api, "_get_hate_path", lambda: str(tmp_path))
|
||||
|
||||
result = api.get_rules_dir()
|
||||
|
||||
assert result == str(tmp_path / "rules")
|
||||
assert result == str(tmp_path / "hashcat" / "rules")
|
||||
assert os.path.isdir(result)
|
||||
|
||||
|
||||
def test_get_hcat_tuning_args_merges_example_default(monkeypatch):
|
||||
monkeypatch.setattr(api, "_resolve_config_path", lambda: None)
|
||||
|
||||
result = api.get_hcat_tuning_args()
|
||||
|
||||
# config.json.example ships hcatTuning: "" — merged default, empty split
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_get_hcat_tuning_args_config_overrides_default(tmp_path, monkeypatch):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({"hcatTuning": "-O -w 3"}))
|
||||
monkeypatch.setattr(api, "_resolve_config_path", lambda: str(config_file))
|
||||
|
||||
result = api.get_hcat_tuning_args()
|
||||
|
||||
assert result == ["-O", "-w", "3"]
|
||||
|
||||
|
||||
def test_cleanup_torrent_files_removes_only_torrents(tmp_path):
|
||||
torrent = tmp_path / "a.torrent"
|
||||
keep = tmp_path / "b.txt"
|
||||
|
||||
Reference in New Issue
Block a user