diff --git a/config.json.example b/config.json.example index 55ef714..0f0f749 100644 --- a/config.json.example +++ b/config.json.example @@ -32,7 +32,7 @@ "ollamaAutoResearch": true, "omenTrainingList": "rockyou.txt", "omenMaxCandidates": 100000000, - "pcfgRuleset": "DEFAULT", + "pcfgRuleset": "Default", "pcfgMaxCandidates": 50000000, "pcfgPrinceLingMaxCandidates": 10000000, "check_for_updates": true, diff --git a/hate_crack/api.py b/hate_crack/api.py index 080098c..1d54abc 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -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") diff --git a/hate_crack/main.py b/hate_crack/main.py index 0006432..2563d57 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -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", diff --git a/tests/test_api_downloads.py b/tests/test_api_downloads.py index 42d56bc..0afd6ae 100644 --- a/tests/test_api_downloads.py +++ b/tests/test_api_downloads.py @@ -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") diff --git a/tests/test_config_defaults_load.py b/tests/test_config_defaults_load.py new file mode 100644 index 0000000..e5aed53 --- /dev/null +++ b/tests/test_config_defaults_load.py @@ -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"} diff --git a/tests/test_config_json_example.py b/tests/test_config_json_example.py index a9bd52e..88d681d 100644 --- a/tests/test_config_json_example.py +++ b/tests/test_config_json_example.py @@ -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 diff --git a/tests/test_main_pcfg.py b/tests/test_main_pcfg.py index a30a92e..3574c33 100644 --- a/tests/test_main_pcfg.py +++ b/tests/test_main_pcfg.py @@ -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// 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 diff --git a/tests/test_utils.py b/tests/test_utils.py index 5d01b81..5e3611e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -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 '/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"