keyhog 0.5.37

keyhog: detects leaked credentials in source trees, git history, and cloud storage
Documentation
//! KeyHog CLI: the developer-first secret scanner.
//!
//! All module declarations live in `lib.rs` so the binary and the library
//! share one set of statics (progress counters) and modules. main.rs only
//! contains the entry point.

use clap::Parser;
use keyhog::args::{Cli, Command};
use keyhog::orchestrator::EXIT_SCANNER_PANIC;
use keyhog::{subcommands, FINDINGS_COUNT, SCANNED_CHUNKS, SCANNER_PANICKED, TOTAL_CHUNKS};
use std::io::IsTerminal;
use std::process::ExitCode;
use std::sync::atomic::Ordering;

// Santh STANDARD.md exit-code classes:
// - 0 success / 1 findings present (the "headline" outcomes; not constants here)
// - EXIT_USER_ERROR (2): the caller gave us bad input. Bad CLI flags, malformed
//   config, missing files the user named, unknown detector IDs, etc.
// - EXIT_SYSTEM_ERROR (3): the local environment failed. Underlying IO failures
//   (disk full, permission denied on a path the user did not explicitly name),
//   hardware probe failures, GPU init failures, OS-level panics. These are
//   "not your fault, but the run can't continue."
// - EXIT_SCANNER_PANIC (11): a scanner-thread panic, a strict subcase of system
//   error tracked separately so CI can distinguish a bug from "disk full". A
//   panic always also implies the system-error class.
const EXIT_USER_ERROR: u8 = 2;
const EXIT_SYSTEM_ERROR: u8 = 3;

/// Restore the default SIGPIPE handler so Unix piping works.
///
/// Rust installs `SIG_IGN` for SIGPIPE at startup so a write to a
/// closed pipe surfaces as `Err(BrokenPipe)` instead of killing the
/// process. That's good for libraries - but for a CLI, the standard
/// expectation is `keyhog scan ... | head -1` exits cleanly when
/// `head` closes the pipe (kernel kills with 128+13=141, no error
/// printed). Without this, the user sees an error on stderr and a
/// non-zero exit code from a perfectly normal pipe interaction.
///
/// POSIX-only - Windows has no SIGPIPE.
#[cfg(unix)]
fn reset_sigpipe() {
    // SAFETY: Setting a process-wide signal handler before any
    // worker threads or async runtime are spawned. The default
    // handler (`SIG_DFL`) terminates the process - exactly the
    // behavior we want for a CLI piped into `head`. No memory or
    // resource invariants depend on Rust's `SIG_IGN` default
    // because every fallible write path in the codebase already
    // uses `?` or explicit error handling.
    unsafe {
        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
    }
}

#[cfg(not(unix))]
fn reset_sigpipe() {}

#[tokio::main]
async fn main() -> ExitCode {
    reset_sigpipe();

    // `env::args()` panics on non-UTF-8 args (Linux allows raw-byte
    // paths). The version check only needs to recognize literal ASCII
    // flags, so iterate args_os() and lossy-compare; non-UTF-8 args
    // could not possibly be the `-V` / `--version` literal.
    // kimi-dogfood-2 #134.
    let is_version = std::env::args_os().any(|a| {
        a.to_str()
            .map(|s| s == "-V" || s == "--version")
            .unwrap_or(false)
    });

    // Fast-path: --version skips Ctrl-C handler spawn, tracing
    // subscriber install, and Cli::parse(). The cold-start kimi audit
    // measured this at ~25ms saved per invocation (on top of the 230ms
    // -> 3ms hardware-probe skip already in print_version_info). Net:
    // production CI scripts that probe `keyhog --version` for
    // capability detection see sub-3ms wall-clock instead of ~30ms.
    if is_version {
        print_version_info();
        return ExitCode::SUCCESS;
    }

    tokio::spawn(async move {
        if let Ok(()) = tokio::signal::ctrl_c().await {
            let scanned = SCANNED_CHUNKS.load(std::sync::atomic::Ordering::SeqCst);
            let total = TOTAL_CHUNKS.load(std::sync::atomic::Ordering::SeqCst);
            let findings = FINDINGS_COUNT.load(std::sync::atomic::Ordering::SeqCst);
            eprintln!("\nScan interrupted. {scanned}/{total} files scanned. {findings} findings.");
            std::process::exit(130);
        }
    });

    // Color the log stream only when stderr is a TTY and NO_COLOR is unset;
    // otherwise (pipes, files, CI logs) tracing would emit raw ANSI escape
    // sequences, matching the discipline crate::style::Palette applies to the
    // command surface.
    let log_ansi = {
        use std::io::IsTerminal;
        std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none()
    };
    tracing_subscriber::fmt()
        .with_writer(std::io::stderr)
        .with_ansi(log_ansi)
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env().add_directive(
                "keyhog=warn".parse().unwrap_or_else(|_| {
                    tracing_subscriber::filter::Directive::from(tracing::Level::INFO)
                }),
            ),
        )
        .with_target(false)
        .init();

    let cli = Cli::parse();

    // --version already handled above (fast-path); the field is still
    // valid here in case Cli::parse() surfaces other version-like
    // states (e.g. a future `keyhog --version --json`).
    if cli.version {
        print_version_info();
        return ExitCode::SUCCESS;
    }

    let command_outcome = match cli.command {
        Some(Command::Scan(args)) => subcommands::scan::run(*args).await,
        Some(Command::Hook { command }) => subcommands::hook::run(command),
        Some(Command::Detectors(args)) => subcommands::detectors::run(args),
        Some(Command::Explain(args)) => subcommands::explain::run(args).map(|()| ExitCode::SUCCESS),
        Some(Command::Diff(args)) => subcommands::diff::run(args),
        Some(Command::Calibrate(args)) => {
            subcommands::calibrate::run(args).map(|()| ExitCode::SUCCESS)
        }
        Some(Command::Watch(args)) => subcommands::watch::run(args).map(|()| ExitCode::SUCCESS),
        Some(Command::Completion(args)) => {
            subcommands::completion::run(args);
            return ExitCode::SUCCESS;
        }
        Some(Command::Backend(args)) => subcommands::backend::run(args),
        Some(Command::Doctor(args)) => subcommands::doctor::run(args),
        Some(Command::Update(args)) => subcommands::update::run(args).await,
        Some(Command::Repair(args)) => subcommands::repair::run(args).await,
        Some(Command::Uninstall(args)) => subcommands::uninstall::run(args),
        Some(Command::ScanSystem(args)) => subcommands::scan_system::run(args),
        #[cfg(unix)]
        Some(Command::Daemon(args)) => subcommands::daemon::run(args).await,
        #[cfg(not(unix))]
        Some(Command::Daemon(_args)) => Err(anyhow::anyhow!(
            "`keyhog daemon` is a unix-only command (it serves scans over a \
             Unix-domain socket). On Windows, run scans in-process: \
             `keyhog scan <path>`. Daemon-mode Windows support (via named \
             pipes) is tracked but not yet implemented."
        )),
        #[cfg(feature = "tui")]
        Some(Command::Tui(args)) => subcommands::tui::run(args),
        None => {
            use clap::CommandFactory;
            let mut cmd = Cli::command();
            let _ = cmd.print_help();
            return ExitCode::from(0);
        }
    };

    match command_outcome {
        Ok(outcome) => {
            if SCANNER_PANICKED.load(Ordering::Relaxed) {
                ExitCode::from(EXIT_SCANNER_PANIC)
            } else {
                outcome
            }
        }
        Err(error) => {
            // {:#} prints the chained user-facing message
            // (`anyhow!("loading detectors").context("…").context("…")`
            // -> "loading detectors: <inner>: <root>") instead of the
            // {:?} debug dump that includes Backtrace internals.
            eprintln!("error: {error:#}");
            let code = if keyhog::SCANNER_PANICKED.load(std::sync::atomic::Ordering::SeqCst) {
                // Scanner panic IS a system error (class EXIT_SYSTEM_ERROR);
                // the EXIT_SCANNER_PANIC subcode (11) is the specific
                // marker so CI can tell a panic from disk-full.
                EXIT_SCANNER_PANIC
            } else if error.chain().any(|e| e.is::<std::io::Error>()) {
                EXIT_SYSTEM_ERROR
            } else {
                EXIT_USER_ERROR
            };
            ExitCode::from(code)
        }
    }
}

fn print_version_info() {
    println!("KeyHog v{}", env!("CARGO_PKG_VERSION"));
    println!(
        "Build Target: {}-{}",
        std::env::consts::ARCH,
        std::env::consts::OS
    );
    println!(
        "ML Model Version: {}",
        keyhog_scanner::ml_scorer::model_version()
    );
    // Hardware probe lives behind KEYHOG_VERSION_FULL=1 because the
    // GPU portion initializes the entire wgpu/Vulkan stack (~200 ms +
    // a 134 MB MAP_SHARED segment), which made `keyhog --version` 9×
    // slower than `--help`. Production CI scripts that probe `keyhog
    // --version` for capability detection hit that delay on every
    // pipeline tick. Hardware info still lives in `keyhog backend`,
    // and the env-var path keeps the original output available for
    // users who scripted against it.
    if std::env::var_os("KEYHOG_VERSION_FULL").is_none() {
        return;
    }
    let hw = keyhog_scanner::hw_probe::probe_hardware();
    if hw.gpu_available {
        // The number `hw.gpu_vram_mb` returns is `limits.max_buffer_size`,
        // NOT actual VRAM - wgpu/WebGPU has no portable VRAM query, and
        // NVIDIA drivers routinely return the wgpu cap (256 GB) here.
        // Calling that "VRAM" is misleading on every laptop GPU we've
        // shipped to. Show the accurate label so an 8 GB RTX 3000 Ada
        // doesn't look like a 256 GB card.
        println!(
            "GPU Acceleration: {}{}",
            hw.gpu_name.as_deref().unwrap_or("available"),
            hw.gpu_vram_mb
                .map(|mb| {
                    if mb >= 1024 {
                        format!(" (max buffer {} GB)", mb / 1024)
                    } else {
                        format!(" (max buffer {mb} MB)")
                    }
                })
                .unwrap_or_default()
        );
    } else {
        println!("GPU Acceleration: not detected");
    }
    if hw.hyperscan_available {
        println!("SIMD Regex:       vectorscan/hyperscan (active)");
    } else if hw.has_avx512 || hw.has_avx2 || hw.has_neon {
        let simd = if hw.has_avx512 {
            "AVX-512"
        } else if hw.has_avx2 {
            "AVX2"
        } else {
            "NEON"
        };
        println!("SIMD Regex:       {simd} (no Hyperscan)");
    } else {
        println!("SIMD Regex:       not available");
    }
    if hw.io_uring_available {
        println!("io_uring:         available");
    }
}

/// Print the animated amber-gradient KEYHOG banner to stderr.
pub fn print_banner(detector_count: usize) {
    if !std::io::stderr().is_terminal() {
        return;
    }

    let mut stderr = std::io::stderr();
    let _ = keyhog_core::banner::print_banner(&mut stderr, true, true, detector_count);
    eprintln!();
}