diff --git a/hate_crack/noninteractive.py b/hate_crack/noninteractive.py index 9790fdb..f473f5c 100644 --- a/hate_crack/noninteractive.py +++ b/hate_crack/noninteractive.py @@ -38,3 +38,51 @@ def build_rule_chains(ctx: Any, rule_tokens: list[str] | None) -> list[str]: raise ValueError(f"Rule token {token!r} resolved to no rule files") chains.append(chain) return chains + + +def run_noninteractive(ctx: Any, args: Any) -> int: + """Run a non-interactive attack. Returns a process exit code. + + ``ctx`` is the main module (live ``hcat*`` functions, ``rulesDirectory``, + ``resolve_path``, ``hcatHashType``/``hcatHashFile`` already set by the + preprocessing block in ``main()``). ``args`` is the parsed subparser + namespace whose ``command`` selects the attack. + """ + command = args.command + + if command == "quick": + wordlist = ctx.resolve_path(args.wordlist) + if not wordlist or not os.path.isfile(wordlist): + print(f"Error: wordlist not found: {args.wordlist}") + return 1 + try: + chains = build_rule_chains(ctx, args.rules) + except (FileNotFoundError, ValueError) as exc: + print(f"Error: invalid --rules value: {exc}") + return 1 + for chain in chains: + ctx.hcatQuickDictionary( + ctx.hcatHashType, + ctx.hcatHashFile, + chain, + wordlist, + attack_name="Quick Crack", + ) + return 0 + + if command == "dict": + ctx.hcatDictionary(ctx.hcatHashType, ctx.hcatHashFile) + return 0 + + if command == "brute": + ctx.hcatBruteForce( + ctx.hcatHashType, ctx.hcatHashFile, args.min_len, args.max_len + ) + return 0 + + if command == "topmask": + ctx.hcatTopMask(ctx.hcatHashType, ctx.hcatHashFile, args.target_time * 3600) + return 0 + + print(f"Error: unknown non-interactive command: {command}") + return 2 diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py index 3fd50e8..e06283f 100644 --- a/tests/test_noninteractive.py +++ b/tests/test_noninteractive.py @@ -53,3 +53,84 @@ def test_build_rule_chains_empty_token_raises(tmp_path): ctx = _ctx_with_rules(tmp_path, "best64.rule") with pytest.raises(ValueError): ni.build_rule_chains(ctx, ["+"]) + + +def _spy_ctx(tmp_path, **overrides): + calls = [] + + def rec(name): + def _fn(*a, **k): + calls.append((name, a, k)) + return _fn + + ctx = SimpleNamespace( + calls=calls, + hcatHashType="1000", + hcatHashFile=str(tmp_path / "hashes.txt"), + rulesDirectory=str(tmp_path / "rules"), + resolve_path=lambda p: os.path.abspath(os.path.expanduser(p)) if p else None, + hcatQuickDictionary=rec("hcatQuickDictionary"), + hcatDictionary=rec("hcatDictionary"), + hcatBruteForce=rec("hcatBruteForce"), + hcatTopMask=rec("hcatTopMask"), + ) + for k, v in overrides.items(): + setattr(ctx, k, v) + return ctx + + +def test_dispatch_quick_calls_quick_dictionary(tmp_path): + (tmp_path / "rules").mkdir() + (tmp_path / "rules" / "best64.rule").write_text(":\n") + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="quick", wordlist=str(wl), rules=["best64.rule"]) + code = ni.run_noninteractive(ctx, args) + assert code == 0 + assert [c[0] for c in ctx.calls] == ["hcatQuickDictionary"] + name, a, k = ctx.calls[0] + assert a[0] == "1000" + assert a[1] == ctx.hcatHashFile + assert a[2] == f"-r {os.path.join(ctx.rulesDirectory, 'best64.rule')}" + assert a[3] == os.path.abspath(str(wl)) + + +def test_dispatch_quick_missing_wordlist_returns_1(tmp_path): + (tmp_path / "rules").mkdir() + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="quick", wordlist=str(tmp_path / "nope.txt"), rules=[]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_dispatch_quick_unknown_rule_returns_1(tmp_path): + (tmp_path / "rules").mkdir() + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="quick", wordlist=str(wl), rules=["ghost.rule"]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_dispatch_dict_calls_dictionary(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="dict") + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][0] == "hcatDictionary" + assert ctx.calls[0][1] == ("1000", ctx.hcatHashFile) + + +def test_dispatch_brute_calls_bruteforce_with_lengths(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="brute", min_len=2, max_len=6) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == ("hcatBruteForce", ("1000", ctx.hcatHashFile, 2, 6), {}) + + +def test_dispatch_topmask_converts_hours_to_seconds(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="topmask", target_time=4) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == ("hcatTopMask", ("1000", ctx.hcatHashFile, 4 * 3600), {})