use crate::args::HookCommand;
use anyhow::{Context, Result};
use std::io::Write;
use std::path::PathBuf;
use std::process::ExitCode;
pub(crate) const CANONICAL_SCAN_ARGS: &str = "scan --fast --git-staged --backend cpu";
#[doc(hidden)]
pub(crate) 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";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HookInstallStatus {
Installed,
AlreadyInstalled,
Updated,
}
pub(crate) async fn run(command: HookCommand) -> Result<ExitCode> {
match command {
HookCommand::Install { force } => install(force),
HookCommand::Uninstall => uninstall(),
HookCommand::Run(args) => run_hook_scan(*args).await,
}
}
async fn run_hook_scan(mut args: crate::args::ScanArgs) -> Result<ExitCode> {
let _hook_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
if args.input.is_empty() && args.path.is_none() && !args.stdin {
#[cfg(feature = "git")]
{
if args.git_blobs.is_none()
&& args.git_diff.is_none()
&& args.git_history.is_none()
&& !args.git_staged
{
args.git_staged = true;
}
}
#[cfg(not(feature = "git"))]
{
}
}
if !args.deep && !args.precision {
args.fast = true;
}
drop(_hook_span);
crate::subcommands::scan::run(args).await
}
fn install(force: bool) -> Result<ExitCode> {
let cur_dir = std::env::current_dir().context("resolving current directory")?;
let (hook_path, status) = install_at_repo(&cur_dir, force)?;
let palette = crate::style::for_stderr();
match status {
HookInstallStatus::AlreadyInstalled => {
let msg = format!(
"KeyHog pre-commit hook is already installed at {}.",
hook_path.display()
);
eprintln!("{}", crate::style::warn(&msg, &palette));
}
HookInstallStatus::Installed => {
let msg = format!(
"KeyHog pre-commit hook installed at {}.",
hook_path.display()
);
eprintln!("{}", crate::style::pass(&msg, &palette));
}
HookInstallStatus::Updated => {
let msg = format!(
"KeyHog pre-commit hook installed/updated at {}.",
hook_path.display()
);
eprintln!("{}", crate::style::pass(&msg, &palette));
}
}
Ok(ExitCode::SUCCESS)
}
pub(crate) fn install_at_repo(
repo_root: &std::path::Path,
force: bool,
) -> Result<(PathBuf, HookInstallStatus)> {
let _check_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
let hooks_dir = find_hooks_dir_for_repo(repo_root)?;
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()))?;
let mut is_update = false;
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 {
return Ok((hook_path, HookInstallStatus::AlreadyInstalled));
}
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()
);
}
is_update = true;
}
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 status = if is_update || force {
HookInstallStatus::Updated
} else {
HookInstallStatus::Installed
};
Ok((hook_path, status))
}
fn uninstall() -> Result<ExitCode> {
let _check_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
let cur_dir = std::env::current_dir().context("resolving current directory")?;
let hooks_dir = find_hooks_dir_for_repo(&cur_dir)?;
let hook_path = 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)
}
pub(crate) fn uninstall_at_repo(repo_root: &std::path::Path) -> Result<Option<PathBuf>> {
let _check_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
let hooks_dir = match find_hooks_dir_for_repo(repo_root) {
Ok(dir) => dir,
Err(_) => return Ok(None),
};
let hook_path = hooks_dir.join("pre-commit");
if !hook_path.exists() {
return Ok(None);
}
let existing = std::fs::read_to_string(&hook_path)
.with_context(|| format!("reading hook at {}", hook_path.display()))?;
if !existing.contains(HOOK_MARKER) {
return Ok(None);
}
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);
Ok(Some(hook_path))
}
pub(crate) fn find_hooks_dir_for_repo(repo_root: &std::path::Path) -> 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)
.current_dir(repo_root)
.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)
}