keyhog 0.5.44

keyhog detects leaked credentials in source trees, git history, archives, and remote sources
Documentation
use keyhog::testing::{CliTestApi as _, API};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
#[cfg(unix)]
use std::process::Command;
#[cfg(unix)]
use tempfile::TempDir;

#[test]
fn hook_template_embeds_canonical_scan_args() {
    let canonical = API.canonical_scan_args();
    let expected = format!("exec keyhog {canonical}\n");
    assert!(
        API.hook_content().contains(&expected),
        "API.hook_content() must invoke `{expected}` verbatim; the installed pre-commit hook diverged from API.canonical_scan_args()"
    );
    assert!(API.canonical_scan_args().contains("--git-staged"));
    assert!(API.canonical_scan_args().contains("--fast"));
    assert!(API.canonical_scan_args().contains("--backend cpu"));
}

fn repo_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("..")
        .join("..")
}

#[test]
fn pre_commit_hooks_yaml_matches_canonical_scan_args() {
    let path = repo_root().join(".pre-commit-hooks.yaml");
    let yaml = std::fs::read_to_string(&path)
        .unwrap_or_else(|err| panic!("reading {}: {err}", path.display()));

    let canonical = API.canonical_scan_args();
    let expected_entry = format!("entry: keyhog {canonical}");
    assert!(
        yaml.contains(&expected_entry),
        ".pre-commit-hooks.yaml must contain `{expected_entry}` so the framework hook mirrors `hook install`; found:\n{yaml}"
    );
    assert!(
        yaml.contains("pass_filenames: false"),
        ".pre-commit-hooks.yaml must keep `pass_filenames: false`; a true value appends filenames and aborts every commit with clap exit 2"
    );
    assert!(
        yaml.contains("always_run: true"),
        ".pre-commit-hooks.yaml must run for binary-only change sets so the staged source can report unscanned blobs as coverage gaps"
    );

    let hooks: serde_yaml::Value = serde_yaml::from_str(&yaml)
        .unwrap_or_else(|err| panic!("parsing {}: {err}", path.display()));
    let hook = hooks
        .as_sequence()
        .and_then(|entries| entries.first())
        .and_then(serde_yaml::Value::as_mapping)
        .expect(".pre-commit-hooks.yaml must contain one hook mapping");
    assert!(
        !hook.contains_key(serde_yaml::Value::String("types".into())),
        ".pre-commit-hooks.yaml must not filter by pre-commit file type; archive-only and binary-only staged changes still require a scan"
    );
}

#[test]
fn scripts_pre_commit_matches_canonical_scan_args() {
    let path = repo_root().join("scripts").join("pre-commit");
    let script = std::fs::read_to_string(&path)
        .unwrap_or_else(|err| panic!("reading {}: {err}", path.display()));

    let canonical = API.canonical_scan_args();
    let expected_exec = format!("exec keyhog {canonical}");
    assert!(
        script.contains(&expected_exec),
        "scripts/pre-commit must invoke `{expected_exec}` verbatim; found:\n{script}"
    );
    for forbidden in [
        "KEYHOG_",
        "--detectors",
        "--path",
        "git show",
        "git cat-file",
        "2>/dev/null",
        "grep -c",
    ] {
        assert!(
            !script.contains(forbidden),
            "scripts/pre-commit must not carry legacy staged-tree-copy behavior `{forbidden}`; found:\n{script}"
        );
    }
    assert!(
        script.contains("keyhog: not found on PATH - blocking commit"),
        "scripts/pre-commit must block when the scanner is missing; found:\n{script}"
    );
    assert!(
        !script.contains("skipping the pre-commit secret scan"),
        "scripts/pre-commit must not silently bypass the installed security control; found:\n{script}"
    );
}

#[cfg(unix)]
fn make_executable(path: &std::path::Path, body: &str) {
    std::fs::write(path, body).expect("write executable");
    let mut perms = std::fs::metadata(path).expect("metadata").permissions();
    perms.set_mode(0o755);
    std::fs::set_permissions(path, perms).expect("chmod executable");
}

#[cfg(unix)]
#[test]
fn scripts_pre_commit_propagates_scanner_failure() {
    let dir = TempDir::new().expect("tempdir");
    let bin_dir = dir.path().join("bin");
    std::fs::create_dir(&bin_dir).expect("bin dir");
    let args_file = dir.path().join("args.txt");
    make_executable(
        &bin_dir.join("keyhog"),
        r#"#!/bin/sh
printf '%s\n' "$@" > "$KEYHOG_ARGS_FILE"
exit 2
"#,
    );

    let output = Command::new(repo_root().join("scripts").join("pre-commit"))
        .env("PATH", &bin_dir)
        .env("KEYHOG_ARGS_FILE", &args_file)
        .output()
        .expect("run scripts/pre-commit");

    assert_eq!(
        output.status.code(),
        Some(2),
        "scripts/pre-commit must propagate scanner failures; stdout={} stderr={}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert_eq!(
        std::fs::read_to_string(&args_file).expect("read args"),
        "scan\n--fast\n--git-staged\n--backend\ncpu\n"
    );
}

#[cfg(unix)]
#[test]
fn scripts_pre_commit_missing_keyhog_blocks_loudly() {
    let dir = TempDir::new().expect("tempdir");
    let output = Command::new(repo_root().join("scripts").join("pre-commit"))
        .env("PATH", dir.path())
        .output()
        .expect("run scripts/pre-commit");

    assert_eq!(
        output.status.code(),
        Some(127),
        "missing keyhog must block instead of bypassing the hook; stdout={} stderr={}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("keyhog: not found on PATH - blocking commit because the pre-commit secret scan did not run."),
        "missing-binary block must stay operator-visible; stderr={stderr}"
    );
    assert!(
        stderr.contains("fix PATH"),
        "missing-binary block must tell the operator how to repair PATH; stderr={stderr}"
    );
}