keyhog 0.5.73

GPU-accelerated secret scanner for code, Git history, cloud, containers, browser assets, and live credential verification
//! Logic for the `hook` subcommand.

use crate::args::HookCommand;
use anyhow::{Context, Result};
use std::io::Write;
use std::path::PathBuf;
use std::process::ExitCode;

/// The canonical `keyhog scan` invocation that EVERY pre-commit mechanism
/// must run, verbatim. This is the single source of truth: the git-hooks
/// template below (`HOOK_CONTENT`, written by `hook install`), the
/// pre-commit framework hook (`.pre-commit-hooks.yaml`, id `keyhog`), and
/// the local script hook (`scripts/pre-commit`) all claim to mirror this
/// command. The external coherence tests in `crates/cli/tests/unit`
/// assert the template, the YAML, and the script all line up with `keyhog
/// CANONICAL_SCAN_ARGS`, so the three paths can never silently drift (e.g.
/// `pass_filenames` flipping true, or a missing `--git-staged`, which would
/// abort every adopter's commit with clap exit 2).
///
/// `--fast`: cheap pre-commit pass; CI is the deeper gate, not a substitute
/// for the local hook running when installed.
/// `--git-staged`: self-discovers staged blobs and scans their STAGED
/// content, taking no positional filename (the framework otherwise appends
/// changed filenames, and `keyhog scan` accepts only ONE positional PATH).
/// `--backend cpu`: hooks are diagnostic local checks and must not require
/// install-time autoroute calibration or an optional Hyperscan library.
const CANONICAL_SCAN_ARGS: &str = "scan --fast --git-staged --backend cpu";

#[doc(hidden)]
const HOOK_CONTENT: &str = concat!(
    "#!/bin/sh\n",
    "# KeyHog pre-commit hook, auto-generated by `keyhog hook install`\n",
    "#\n",
    "# If keyhog is not on PATH, block with a clear message. A missing scanner\n",
    "# means this security control did not run; letting the commit continue would\n",
    "# silently turn the installed hook into a stub.\n",
    "if ! command -v keyhog >/dev/null 2>&1; then\n",
    "    echo \"keyhog: not found on PATH - blocking commit because the pre-commit secret scan did not run.\" >&2\n",
    "    echo \"  Install keyhog (https://github.com/santhreal/keyhog), fix PATH,\" >&2\n",
    "    echo \"  or run 'keyhog hook uninstall' if this repository should not be protected.\" >&2\n",
    "    exit 127\n",
    "fi\n",
    "exec keyhog ",
    "scan --fast --git-staged --backend cpu", // == CANONICAL_SCAN_ARGS; asserted by hook_template_embeds_canonical_scan_args
    "\n",
);

#[doc(hidden)]
pub(crate) mod testing {
    pub(crate) const CANONICAL_SCAN_ARGS: &str = super::CANONICAL_SCAN_ARGS;
    pub(crate) const HOOK_CONTENT: &str = super::HOOK_CONTENT;
}

const HOOK_MARKER: &str = "KeyHog pre-commit hook";

pub(crate) fn run(command: HookCommand) -> Result<ExitCode> {
    match command {
        HookCommand::Install { force } => install(force),
        HookCommand::Uninstall => uninstall(),
    }
}

fn install(force: bool) -> Result<ExitCode> {
    // Hook path resolution + existing-hook check: the collect phase.
    let _check_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
    let hooks_dir = find_hooks_dir()?;
    let hook_path = hooks_dir.join("pre-commit");

    std::fs::create_dir_all(&hooks_dir)
        .with_context(|| format!("creating hooks directory at {}", hooks_dir.display()))?;

    if hook_path.exists() {
        let existing = std::fs::read_to_string(&hook_path)
            .with_context(|| format!("reading existing hook at {}", hook_path.display()))?;
        // Exact-byte match is the only "already installed" no-op. A marker
        // alone is not enough: upgraded HOOK_CONTENT must rewrite so operators
        // never keep a stale scan line after a keyhog upgrade (KH-1333).
        if existing == HOOK_CONTENT && !force {
            let palette = crate::style::for_stderr();
            let msg = format!(
                "KeyHog pre-commit hook is already installed at {}.",
                hook_path.display()
            );
            eprintln!("{}", crate::style::warn(&msg, &palette));
            return Ok(ExitCode::SUCCESS);
        }
        let is_keyhog_owned = existing.contains(HOOK_MARKER);
        if !force && !is_keyhog_owned {
            anyhow::bail!(
                "a pre-commit hook already exists at {}. Remove it manually, \
                 run `keyhog hook uninstall` if it was installed by KeyHog, \
                 or pass `--force` to replace it.",
                hook_path.display()
            );
        }
        // KeyHog-owned but bytes differ (or --force): fall through and rewrite.
    }

    let mut file = std::fs::OpenOptions::new()
        .write(true)
        .create(true)
        .truncate(true)
        .open(&hook_path)
        .with_context(|| format!("creating hook at {}", hook_path.display()))?;
    drop(_check_span);
    // Hook publication (write + chmod + receipt).
    let _report_span = keyhog_profile::span(keyhog_profile::Stage::Reporting);

    file.write_all(HOOK_CONTENT.as_bytes())
        .with_context(|| format!("writing hook to {}", hook_path.display()))?;
    drop(file);

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = std::fs::metadata(&hook_path)
            .with_context(|| format!("reading permissions of {}", hook_path.display()))?
            .permissions();
        perms.set_mode(perms.mode() | 0o111);
        std::fs::set_permissions(&hook_path, perms)
            .with_context(|| format!("making {} executable", hook_path.display()))?;
    }

    let palette = crate::style::for_stderr();
    let msg = format!(
        "KeyHog pre-commit hook {} at {}.",
        if force {
            "installed/updated"
        } else {
            "installed"
        },
        hook_path.display(),
    );
    eprintln!("{}", crate::style::pass(&msg, &palette));
    Ok(ExitCode::SUCCESS)
}

fn uninstall() -> Result<ExitCode> {
    // Hook path resolution + ownership check: the collect phase.
    let _check_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
    let hook_path = find_hooks_dir()?.join("pre-commit");

    if !hook_path.exists() {
        let palette = crate::style::for_stderr();
        let msg = format!("No pre-commit hook found at {}.", hook_path.display());
        eprintln!("{}", crate::style::warn(&msg, &palette));
        return Ok(ExitCode::SUCCESS);
    }

    let existing = std::fs::read_to_string(&hook_path)
        .with_context(|| format!("reading hook at {}", hook_path.display()))?;

    if !existing.contains(HOOK_MARKER) {
        anyhow::bail!(
            "pre-commit hook at {} was not installed by KeyHog. Remove it manually if you are sure.",
            hook_path.display()
        );
    }

    std::fs::remove_file(&hook_path)
        .with_context(|| format!("removing hook at {}", hook_path.display()))?;
    drop(_check_span);

    // Publication of the removal receipt.
    let _report_span = keyhog_profile::span(keyhog_profile::Stage::Reporting);
    let palette = crate::style::for_stderr();
    let msg = format!(
        "KeyHog pre-commit hook removed from {}.",
        hook_path.display()
    );
    eprintln!("{}", crate::style::pass(&msg, &palette));
    Ok(ExitCode::SUCCESS)
}

fn find_hooks_dir() -> Result<PathBuf> {
    // SECURITY: kimi-wave1 audit finding 3.PATH-git. Use trusted absolute path.
    let git_bin = keyhog_core::resolve_safe_bin("git")
        .ok_or_else(|| anyhow::anyhow!("git binary not found in trusted system bin dirs"))?;
    let output = std::process::Command::new(&git_bin)
        .args(["rev-parse", "--path-format=absolute", "--git-path", "hooks"])
        .output()
        .context("failed to run `git rev-parse --git-path hooks`. Is this a git repository?")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!("not a git repository (or git failed): {stderr}");
    }

    let hooks_dir = String::from_utf8(output.stdout)
        .context("git output is not valid UTF-8")?
        .trim()
        .to_string();
    anyhow::ensure!(
        !hooks_dir.is_empty(),
        "git returned an empty hooks path. Fix: check core.hooksPath and repository metadata."
    );
    let path = PathBuf::from(hooks_dir);
    anyhow::ensure!(
        path.is_absolute(),
        "git returned a relative hooks path despite --path-format=absolute: {}. Fix: update Git or repair core.hooksPath.",
        path.display()
    );
    Ok(path)
}