diff --git a/hate_crack/noninteractive.py b/hate_crack/noninteractive.py new file mode 100644 index 0000000..154e615 --- /dev/null +++ b/hate_crack/noninteractive.py @@ -0,0 +1,38 @@ +"""Non-interactive (scripted) attack entry points for hate_crack. + +These helpers translate parsed argparse namespaces into calls against the +existing ``hcat*`` attack functions on the main module (passed in as ``ctx``, +the same pattern ``attacks.py`` uses). See +``docs/superpowers/specs/2026-07-24-cli-noninteractive-design.md``. +""" + +import os +from typing import Any + + +def build_rule_chains(ctx: Any, rule_tokens): + """Convert CLI ``--rules`` tokens into hashcat ``-r`` chain strings. + + Each token becomes one attack pass. A token may chain multiple rule files + with ``+`` (mirroring the interactive rule selector). Filenames resolve + against ``ctx.rulesDirectory``. Returns ``[""]`` when no rules are given + (equivalent to the interactive "run without rules" choice). + + Raises ``FileNotFoundError`` (with the offending filename as its argument) + if any named rule file is missing. + """ + if not rule_tokens: + return [""] + chains = [] + for token in rule_tokens: + chain = "" + for name in token.split("+"): + name = name.strip() + if not name: + continue + path = os.path.join(ctx.rulesDirectory, name) + if not os.path.isfile(path): + raise FileNotFoundError(name) + chain = f"{chain} -r {path}".strip() + chains.append(chain) + return chains diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py new file mode 100644 index 0000000..194e801 --- /dev/null +++ b/tests/test_noninteractive.py @@ -0,0 +1,47 @@ +import os +from types import SimpleNamespace + +import pytest + +from hate_crack import noninteractive as ni + + +def _ctx_with_rules(tmp_path, *rule_names): + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + for name in rule_names: + (rules_dir / name).write_text(":\n") + return SimpleNamespace(rulesDirectory=str(rules_dir)) + + +def test_build_rule_chains_no_rules_returns_empty_chain(tmp_path): + ctx = _ctx_with_rules(tmp_path) + assert ni.build_rule_chains(ctx, []) == [""] + assert ni.build_rule_chains(ctx, None) == [""] + + +def test_build_rule_chains_single_rule(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule") + chains = ni.build_rule_chains(ctx, ["best64.rule"]) + expected = os.path.join(ctx.rulesDirectory, "best64.rule") + assert chains == [f"-r {expected}"] + + +def test_build_rule_chains_chained_token(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule", "d3ad0ne.rule") + chains = ni.build_rule_chains(ctx, ["best64.rule+d3ad0ne.rule"]) + a = os.path.join(ctx.rulesDirectory, "best64.rule") + b = os.path.join(ctx.rulesDirectory, "d3ad0ne.rule") + assert chains == [f"-r {a} -r {b}"] + + +def test_build_rule_chains_multiple_tokens_are_separate_passes(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule", "d3ad0ne.rule") + chains = ni.build_rule_chains(ctx, ["best64.rule", "d3ad0ne.rule"]) + assert len(chains) == 2 + + +def test_build_rule_chains_missing_file_raises(tmp_path): + ctx = _ctx_with_rules(tmp_path) + with pytest.raises(FileNotFoundError): + ni.build_rule_chains(ctx, ["nope.rule"])