keyhog 0.5.73

GPU-accelerated secret scanner for code, Git history, cloud, containers, browser assets, and live credential verification
//! End-to-end tests for the canonical `keyhog hook install` and `uninstall`
//! surface.

use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;

fn binary() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}

fn repository() -> TempDir {
    let dir = TempDir::new().expect("create temporary repository");
    let output = Command::new("git")
        .args(["init", "--quiet"])
        .current_dir(dir.path())
        .output()
        .expect("initialize git repository");
    assert!(
        output.status.success(),
        "git init failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    dir
}

fn run_hook(dir: &TempDir, args: &[&str]) -> std::process::Output {
    Command::new(binary())
        .arg("hook")
        .args(args)
        .current_dir(dir.path())
        .env("NO_COLOR", "1")
        .output()
        .expect("run keyhog hook command")
}

#[test]
fn hook_install_is_executable_and_idempotent() {
    let dir = repository();
    let hook_path = dir.path().join(".git/hooks/pre-commit");

    let first = run_hook(&dir, &["install"]);
    assert_eq!(
        first.status.code(),
        Some(0),
        "install failed: {}",
        String::from_utf8_lossy(&first.stderr)
    );
    let installed = std::fs::read(&hook_path).expect("read installed hook");
    let installed_text = String::from_utf8_lossy(&installed);
    assert!(
        installed_text.contains("exec keyhog scan --fast --git-staged --backend cpu"),
        "installed hook must execute the canonical staged scan: {installed_text}"
    );
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        assert_ne!(
            std::fs::metadata(&hook_path)
                .expect("read installed hook metadata")
                .permissions()
                .mode()
                & 0o111,
            0,
            "installed hook must be executable"
        );
    }

    let second = run_hook(&dir, &["install"]);
    assert_eq!(second.status.code(), Some(0));
    assert_eq!(
        std::fs::read(&hook_path).expect("reread installed hook"),
        installed,
        "idempotent install must preserve the managed hook bytes"
    );
}

#[test]
fn hook_install_preserves_an_unmanaged_hook_unless_forced() {
    let dir = repository();
    let hook_path = dir.path().join(".git/hooks/pre-commit");
    std::fs::create_dir_all(hook_path.parent().expect("hook parent")).expect("create hooks dir");
    let original = b"#!/bin/sh\necho existing-hook\n";
    std::fs::write(&hook_path, original).expect("write unmanaged hook");

    let refused = run_hook(&dir, &["install"]);
    assert_ne!(refused.status.code(), Some(0));
    assert_eq!(
        std::fs::read(&hook_path).expect("read preserved unmanaged hook"),
        original,
        "ordinary install must not overwrite an unmanaged hook"
    );
    assert!(
        String::from_utf8_lossy(&refused.stderr).contains("--force"),
        "refusal must name the explicit replacement control"
    );

    let forced = run_hook(&dir, &["install", "--force"]);
    assert_eq!(
        forced.status.code(),
        Some(0),
        "forced install failed: {}",
        String::from_utf8_lossy(&forced.stderr)
    );
    assert!(
        String::from_utf8_lossy(&std::fs::read(&hook_path).expect("read replaced hook"))
            .contains("KeyHog pre-commit hook"),
        "forced install must replace the unmanaged hook with the managed hook"
    );
}

#[test]
fn hook_uninstall_removes_only_the_managed_hook() {
    let dir = repository();
    let hook_path = dir.path().join(".git/hooks/pre-commit");
    assert_eq!(run_hook(&dir, &["install"]).status.code(), Some(0));

    let removed = run_hook(&dir, &["uninstall"]);
    assert_eq!(
        removed.status.code(),
        Some(0),
        "uninstall failed: {}",
        String::from_utf8_lossy(&removed.stderr)
    );
    assert!(
        !hook_path.exists(),
        "uninstall must remove the managed hook"
    );
    assert_eq!(
        run_hook(&dir, &["uninstall"]).status.code(),
        Some(0),
        "repeated uninstall must be idempotent"
    );

    std::fs::write(&hook_path, "#!/bin/sh\necho unmanaged\n").expect("write unmanaged hook");
    let refused = run_hook(&dir, &["uninstall"]);
    assert_ne!(refused.status.code(), Some(0));
    assert!(
        hook_path.exists(),
        "uninstall must preserve an unmanaged hook"
    );
}

#[test]
fn staged_hook_scope_differs_from_a_full_working_tree_scan() {
    let dir = repository();
    std::fs::write(dir.path().join("tracked.txt"), "ordinary tracked content\n")
        .expect("write tracked file");
    let added = Command::new("git")
        .args(["add", "tracked.txt"])
        .current_dir(dir.path())
        .output()
        .expect("stage tracked file");
    assert!(added.status.success(), "git add must succeed");
    std::fs::write(dir.path().join("untracked.txt"), "token = demo_ABCDEFGH\n")
        .expect("write untracked secret");

    let detectors = TempDir::new().expect("create detector directory");
    std::fs::write(
        detectors.path().join("demo.toml"),
        r#"[detector]
id = "demo-token"
name = "Demo token"
service = "demo"
severity = "high"
ml = { match_mode = "disabled", entropy_mode = "disabled", weight = 0.0, context_radius_lines = 0 }
match_confidence = { literal_prefix_weight = 0.35, context_anchor_weight = 0.20, entropy_weight = 0.20, high_entropy_partial_weight = 0.12, moderate_entropy_threshold = 3.0, moderate_entropy_weight = 0.05, low_entropy_penalty_floor = 2.0, low_entropy_min_match_length = 10, low_entropy_penalty_multiplier = 0.60, keyword_nearby_weight = 0.10, sensitive_file_weight = 0.10, companion_weight = 0.05, very_high_entropy_margin = 1.3, named_anchor_floor = 0.55, assignment_context_multiplier = 1.0, string_literal_context_multiplier = 0.9, unknown_context_multiplier = 0.8, documentation_context_multiplier = 0.3, comment_context_multiplier = 0.4, test_context_multiplier = 0.3, encrypted_context_multiplier = 0.05, soft_context_suppression_threshold = 0.5, encrypted_context_suppression_threshold = 0.8, post_match = { placeholder_multiplier = 0.05, minimum_byte_diversity = 0.1, low_diversity_multiplier = 0.1, maximum_repeat_ratio = 0.8, degenerate_run_min_length = 10, degenerate_repeat_multiplier = 0.1, fixture_path_multiplier = 0.5, ml_context_reapply_below = 0.95 } }
keywords = ["demo_"]
min_confidence = 0.0

[[detector.patterns]]
regex = '(?-i)demo_[A-Z0-9]{8}'
"#,
    )
    .expect("write detector");
    let detector_path = detectors.path().to_string_lossy().to_string();

    let staged = Command::new(binary())
        .args([
            "scan",
            "--fast",
            "--backend",
            "cpu",
            "--format",
            "json",
            "--detectors",
            &detector_path,
            "--git-staged",
        ])
        .current_dir(dir.path())
        .env("NO_COLOR", "1")
        .output()
        .expect("run staged scan");
    assert_eq!(
        staged.status.code(),
        Some(0),
        "staged scan must ignore the untracked secret: {}",
        String::from_utf8_lossy(&staged.stderr)
    );

    let full = Command::new(binary())
        .args([
            "scan",
            "--fast",
            "--backend",
            "cpu",
            "--format",
            "json",
            "--detectors",
            &detector_path,
            ".",
        ])
        .current_dir(dir.path())
        .env("NO_COLOR", "1")
        .output()
        .expect("run full working-tree scan");
    assert_eq!(
        full.status.code(),
        Some(1),
        "full scan must find the untracked secret: {}",
        String::from_utf8_lossy(&full.stderr)
    );
    assert!(
        String::from_utf8_lossy(&full.stdout).contains("demo-token"),
        "full scan must identify the custom detector"
    );
}