use anyhow::{Context, Result};
use std::io::Write;
use std::path::PathBuf;
use std::process::ExitCode;
pub const CANONICAL_SCAN_ARGS: &str = "scan --fast --git-staged";
#[doc(hidden)]
pub 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, skip the scan with a clear message instead of\n",
"# failing the commit. A bare `keyhog ...` here makes `sh` exit 127\n",
"# (\"keyhog: not found\"), which git treats as a hook failure and blocks\n",
"# EVERY commit - including clean ones - with a cryptic error. A pre-commit\n",
"# scan is best-effort (CI is the real gate), so a missing tool must not\n",
"# brick a contributor's workflow.\n",
"if ! command -v keyhog >/dev/null 2>&1; then\n",
" echo \"keyhog: not found on PATH - skipping the pre-commit secret scan.\" >&2\n",
" echo \" Install keyhog (https://github.com/santhsecurity/keyhog) or remove\" >&2\n",
" echo \" .git/hooks/pre-commit to silence this warning.\" >&2\n",
" exit 0\n",
"fi\n",
"exec keyhog ",
"scan --fast --git-staged", "\n",
);
const HOOK_MARKER: &str = "KeyHog pre-commit hook";
#[derive(clap::Subcommand, Debug, Clone)]
pub enum HookCommand {
Install {
#[arg(long, default_value_t = false)]
force: bool,
},
Uninstall,
}
pub fn run(command: HookCommand) -> Result<ExitCode> {
match command {
HookCommand::Install { force } => install(force),
HookCommand::Uninstall => uninstall(),
}
}
fn install(force: bool) -> Result<ExitCode> {
let git_dir = find_git_dir()?;
let hooks_dir = git_dir.join("hooks");
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()))?;
if existing.contains(HOOK_MARKER) && !force {
eprintln!(
"KeyHog pre-commit hook is already installed at {}.",
hook_path.display()
);
return Ok(ExitCode::SUCCESS);
}
if !force {
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()
);
}
}
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()))?;
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()))?;
}
eprintln!(
"KeyHog pre-commit hook {} at {}.",
if force {
"installed/updated"
} else {
"installed"
},
hook_path.display(),
);
Ok(ExitCode::SUCCESS)
}
fn uninstall() -> Result<ExitCode> {
let git_dir = find_git_dir()?;
let hook_path = git_dir.join("hooks").join("pre-commit");
if !hook_path.exists() {
eprintln!("No pre-commit hook found at {}.", hook_path.display());
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()))?;
eprintln!(
"KeyHog pre-commit hook removed from {}.",
hook_path.display()
);
Ok(ExitCode::SUCCESS)
}
fn find_git_dir() -> Result<PathBuf> {
let git_bin = keyhog_core::safe_bin::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", "--git-dir"])
.output()
.context("failed to run `git rev-parse --git-dir`. 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 git_dir = String::from_utf8(output.stdout)
.context("git output is not valid UTF-8")?
.trim()
.to_string();
let path = PathBuf::from(git_dir);
let absolute = if path.is_absolute() {
path
} else {
std::env::current_dir()
.context("getting current directory")?
.join(path)
};
Ok(absolute.canonicalize().unwrap_or(absolute))
}