keyhog 0.5.84

GPU-accelerated secret scanner for code, Git history, cloud, containers, browser assets, and live credential verification
//! Shared helpers for end-to-end binary tests.
//!
//! `#[path]`-included by five test binaries (`e2e_all`, `all_tests`,
//! `gap_all`, `stress_all`, `dogfood_all`), each of which needs a different
//! subset. Per-binary dead-code warnings here are an artifact of that sharing,
//! not unused code.
#![allow(dead_code)]

use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::{LazyLock, Mutex, MutexGuard};
use tempfile::TempDir;

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

pub fn keyhog_command(args: &[&str]) -> Command {
    let mut cmd = Command::new(binary());
    apply_default_scan_backend(&mut cmd, args);
    cmd
}

/// The diagnostic `--backend` this build can actually dispatch.
///
/// A portable build carries no Hyperscan, and an explicit `--backend simd`
/// against it is refused with exit 2 rather than quietly substituted, so a
/// hardcoded `simd` turns a correct routing refusal into what reads as a
/// product failure. `cargo test -p keyhog` on default features is a real
/// configuration; CI's `--features simd` run is not the only one.
#[cfg(feature = "simd")]
pub const DIAGNOSTIC_BACKEND: &str = "simd";
#[cfg(not(feature = "simd"))]
pub const DIAGNOSTIC_BACKEND: &str = "cpu";

/// The materialized route name `DIAGNOSTIC_BACKEND` resolves to in the routing
/// decision line.
#[cfg(feature = "simd")]
pub const DIAGNOSTIC_BACKEND_ROUTE: &str = "simd-regex";
#[cfg(not(feature = "simd"))]
pub const DIAGNOSTIC_BACKEND_ROUTE: &str = "cpu-fallback";

pub fn apply_default_scan_backend(cmd: &mut Command, args: &[&str]) {
    if args.first() == Some(&"scan") {
        cmd.arg("scan");
        if !args.iter().any(|arg| *arg == "--backend") {
            cmd.args(["--backend", DIAGNOSTIC_BACKEND]);
        }
        if !args
            .iter()
            .any(|arg| *arg == "--developer-compile-embedded-detectors")
        {
            cmd.arg("--developer-compile-embedded-detectors");
        }
        cmd.args(&args[1..]);
    } else {
        cmd.args(args);
    }
}

pub fn run(args: &[&str]) -> Output {
    keyhog_command(args).output().expect("spawn keyhog")
}

pub fn workspace_detectors() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../../detectors")
        .canonicalize()
        .expect("workspace detectors dir")
}

/// Write `content` to a temp file named `name`, scan with `--format json`,
/// return output. The name is load-bearing: the evidence classifier reads the
/// source role from the file syntax, so an assignment in an inert `.txt` is
/// `review`/`unsupported-context` and exits 0, while the same assignment in a
/// `.env` is `likely`/`vendor-pattern` and exits 1.
pub fn scan_named_file(
    name: &str,
    content: &str,
    extra_args: &[&str],
) -> (String, String, Option<i32>) {
    let dir = TempDir::new().expect("tempdir");
    let path = dir.path().join(name);
    std::fs::write(&path, content).expect("write fixture");

    let mut cmd_args: Vec<String> = vec![
        "scan".into(),
        "--daemon=off".into(),
        "--format".into(),
        "json".into(),
        "--backend".into(),
        DIAGNOSTIC_BACKEND.into(),
        "--developer-compile-embedded-detectors".into(),
    ];
    for arg in extra_args {
        cmd_args.push((*arg).into());
    }
    cmd_args.push(path.to_string_lossy().into_owned());

    let output = Command::new(binary())
        .args(&cmd_args)
        .output()
        .expect("spawn keyhog scan");

    (
        String::from_utf8_lossy(&output.stdout).into_owned(),
        String::from_utf8_lossy(&output.stderr).into_owned(),
        output.status.code(),
    )
}

/// `scan_named_file` with the inert `planted.txt` fixture name.
pub fn scan_text_file(content: &str, extra_args: &[&str]) -> (String, String, Option<i32>) {
    scan_named_file("planted.txt", content, extra_args)
}

pub fn write_temp_file(name: &str, content: &str) -> (TempDir, PathBuf) {
    let dir = TempDir::new().expect("tempdir");
    let path = dir.path().join(name);
    std::fs::write(&path, content).expect("write fixture");
    (dir, path)
}

pub fn scan_path(path: &Path, extra_args: &[&str]) -> Output {
    let mut args = vec![
        "scan",
        "--daemon=off",
        "--format",
        "json",
        "--backend",
        DIAGNOSTIC_BACKEND,
        "--developer-compile-embedded-detectors",
    ];
    args.extend(extra_args);
    args.push(path.to_str().expect("utf-8 path"));
    Command::new(binary())
        .args(&args)
        .output()
        .expect("spawn keyhog scan")
}

#[cfg(unix)]
pub struct DaemonGuard {
    _slot: MutexGuard<'static, ()>,
    runtime: TempDir,
    child: std::process::Child,
}

#[cfg(unix)]
fn daemon_slot() -> MutexGuard<'static, ()> {
    static DAEMON_SLOT: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
    DAEMON_SLOT
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner())
}

pub fn autoroute_calibration_slot() -> MutexGuard<'static, ()> {
    static CALIBRATION_SLOT: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
    CALIBRATION_SLOT
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner())
}

#[cfg(unix)]
impl DaemonGuard {
    pub fn start() -> Self {
        Self::start_impl(&[], false, false, None)
    }
    /// Start daemon with embedded detectors (no --detectors flag), so scan
    /// and guard subcommands that use default embedded detector identity
    /// match the daemon's detector rules identity.
    pub fn start_embedded() -> Self {
        Self::start_impl_full(&[], false, false, None, true)
    }

    /// Warm daemon on the portable CPU backend: daemon/profile e2e tests must
    /// pass on hosts without a Hyperscan/SIMD runtime.
    pub fn start_cpu() -> Self {
        Self::start_impl(&[], false, false, Some("cpu"))
    }

    /// Start daemon on CPU backend with embedded detectors (no --detectors
    /// flag), so guard subcommands that use `client::connect()` with
    /// embedded detector identity can match the daemon's warm backend.
    pub fn start_cpu_embedded() -> Self {
        Self::start_impl_full(&[], false, false, Some("cpu"), true)
    }

    /// Mass clients use the embedded corpus unless a test passes `--detectors`;
    /// keep the fixture daemon on that same production-default identity.
    pub fn start_mass() -> Self {
        Self::start_impl_full(&[], true, false, None, true)
    }

    pub fn start_mass_gpu_primary() -> Self {
        Self::start_impl_full(&[], true, true, None, true)
    }

    pub fn start_mass_gpu_primary_with_backend(backend: &'static str) -> Self {
        Self::start_impl_full(&[], true, true, Some(backend), true)
    }

    pub fn start_with_env(envs: &[(&str, &str)]) -> Self {
        Self::start_impl(envs, false, false, None)
    }

    fn start_impl(
        envs: &[(&str, &str)],
        mass: bool,
        mass_gpu_primary: bool,
        backend: Option<&'static str>,
    ) -> Self {
        Self::start_impl_full(envs, mass, mass_gpu_primary, backend, false)
    }

    fn start_impl_full(
        envs: &[(&str, &str)],
        mass: bool,
        mass_gpu_primary: bool,
        backend: Option<&'static str>,
        skip_detectors: bool,
    ) -> Self {
        use std::process::Stdio;
        use std::time::{Duration, Instant};

        let slot = daemon_slot();
        let runtime = TempDir::new().expect("runtime dir");
        let detectors = workspace_detectors();
        let mut cmd = Command::new(binary());
        cmd.env("XDG_RUNTIME_DIR", runtime.path());
        for (key, value) in envs {
            cmd.env(key, value);
        }
        #[cfg(feature = "simd")]
        let fallback_backend = if mass { "cpu" } else { "simd" };
        #[cfg(not(feature = "simd"))]
        let fallback_backend = "cpu";
        let mut daemon_args = vec![
            "daemon",
            "start",
            "--backend",
            backend.unwrap_or(fallback_backend),
        ];
        if !skip_detectors {
            daemon_args.push("--detectors");
            daemon_args.push(detectors.to_str().expect("detectors path"));
        }
        if mass {
            daemon_args.push("--mass");
        }
        if mass_gpu_primary {
            daemon_args.push("--mass-gpu-primary");
        }
        let mut child = cmd
            .args(daemon_args)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .expect("spawn keyhog daemon");

        let socket = runtime.path().join("keyhog.sock");
        let deadline = Instant::now() + Duration::from_secs(120);
        while !socket.exists() {
            if Instant::now() >= deadline {
                let output = child
                    .wait_with_output()
                    .expect("collect timed-out daemon output");
                panic!(
                    "daemon socket did not appear in time; status={:?}; stdout={}; stderr={}",
                    output.status.code(),
                    String::from_utf8_lossy(&output.stdout),
                    String::from_utf8_lossy(&output.stderr)
                );
            }
            if let Some(status) = child.try_wait().expect("poll daemon startup") {
                let output = child
                    .wait_with_output()
                    .expect("collect exited daemon output");
                panic!(
                    "daemon exited before binding socket; status={:?}; stdout={}; stderr={}",
                    status.code(),
                    String::from_utf8_lossy(&output.stdout),
                    String::from_utf8_lossy(&output.stderr)
                );
            }
            std::thread::sleep(Duration::from_millis(50));
        }

        Self {
            _slot: slot,
            runtime,
            child,
        }
    }

    pub fn runtime_dir(&self) -> &Path {
        self.runtime.path()
    }

    /// The Unix socket this daemon is bound to.
    pub fn socket(&self) -> std::path::PathBuf {
        self.runtime.path().join("keyhog.sock")
    }

    /// `None` while the daemon is still running, otherwise its exit status.
    /// Tests that abuse the wire use this to prove the process survived.
    pub fn exited(&mut self) -> Option<std::process::ExitStatus> {
        self.child.try_wait().expect("poll daemon liveness")
    }
}

#[cfg(unix)]
impl Drop for DaemonGuard {
    fn drop(&mut self) {
        let _ = Command::new(binary())
            .env("XDG_RUNTIME_DIR", self.runtime.path())
            .args(["daemon", "stop"])
            .output();
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}