use crate::args::HookCommand;
use anyhow::{Context, Result};
use std::io::Write;
use std::path::PathBuf;
use std::process::ExitCode;
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", "\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> {
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()))?;
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()
);
}
}
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);
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> {
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);
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> {
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)
}