use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Command;
use opencodecommit::config::{Config, DiffSource};
use opencodecommit::context;
use opencodecommit::git;
use opencodecommit::sensitive::{
allows_sensitive_bypass as policy_allows_sensitive_bypass,
scan_diff_for_sensitive_content_with_options,
};
use serde::{Deserialize, Serialize};
const OCC_MANAGED_HOOK_MARKER: &str = "Generated by OpenCodeCommit guard install";
const MANAGED_HOOKS: &[&str] = &[
"applypatch-msg",
"pre-applypatch",
"post-applypatch",
"pre-commit",
"pre-merge-commit",
"prepare-commit-msg",
"commit-msg",
"post-commit",
"pre-rebase",
"post-checkout",
"post-merge",
"pre-push",
"pre-auto-gc",
"post-rewrite",
"sendemail-validate",
"fsmonitor-watchman",
"p4-changelist",
"p4-prepare-changelist",
"p4-post-changelist",
"p4-pre-submit",
"post-index-change",
"reference-transaction",
"pre-receive",
"update",
"proc-receive",
"post-receive",
"post-update",
"push-to-checkout",
];
#[derive(Debug)]
pub enum GuardError {
Occ(opencodecommit::Error),
Io(std::io::Error),
InvalidInstall(String),
}
impl fmt::Display for GuardError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GuardError::Occ(err) => write!(f, "{err}"),
GuardError::Io(err) => write!(f, "{err}"),
GuardError::InvalidInstall(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for GuardError {}
impl From<opencodecommit::Error> for GuardError {
fn from(err: opencodecommit::Error) -> Self {
Self::Occ(err)
}
}
impl From<std::io::Error> for GuardError {
fn from(err: std::io::Error) -> Self {
Self::Io(err)
}
}
pub type Result<T> = std::result::Result<T, GuardError>;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct GuardInstallMetadata {
previous_global_hooks_path: Option<PathBuf>,
}
#[derive(Debug, Clone)]
struct GuardPaths {
root: PathBuf,
hooks_dir: PathBuf,
metadata_path: PathBuf,
}
pub fn install_global() -> Result<String> {
let paths = resolve_guard_paths()?;
std::fs::create_dir_all(&paths.hooks_dir)?;
let existing_metadata = load_metadata().ok();
let existing_global_hooks = git::get_global_hooks_path()?;
let previous_global_hooks_path = match existing_global_hooks.as_deref() {
Some(current) if same_path(current, &paths.hooks_dir) => {
existing_metadata.and_then(|metadata| metadata.previous_global_hooks_path)
}
Some(current) => Some(current.to_path_buf()),
None => existing_metadata.and_then(|metadata| metadata.previous_global_hooks_path),
};
let metadata = GuardInstallMetadata {
previous_global_hooks_path,
};
save_metadata(&metadata)?;
write_managed_hook_wrappers(&paths.hooks_dir, &std::env::current_exe()?)?;
git::set_global_hooks_path(&paths.hooks_dir)?;
Ok(format!(
"installed global OpenCodeCommit guard at {}",
paths.hooks_dir.display()
))
}
pub fn uninstall_global() -> Result<String> {
let paths = resolve_guard_paths()?;
let metadata = load_metadata().unwrap_or_default();
let current_global = git::get_global_hooks_path()?;
if current_global
.as_ref()
.is_some_and(|path| same_path(path, &paths.hooks_dir))
{
if let Some(previous) = metadata.previous_global_hooks_path.as_ref() {
git::set_global_hooks_path(previous)?;
} else {
git::unset_global_hooks_path()?;
}
}
if paths.root.exists() {
std::fs::remove_dir_all(&paths.root)?;
}
Ok("uninstalled global OpenCodeCommit guard".to_owned())
}
pub fn run_managed_hook(hook_name: &str, args: &[String]) -> Result<i32> {
let paths = resolve_guard_paths()?;
let metadata = load_metadata_from(&paths.metadata_path).unwrap_or_default();
run_managed_hook_with_state(&paths.hooks_dir, &metadata, hook_name, args)
}
fn run_managed_hook_with_state(
hooks_dir: &Path,
metadata: &GuardInstallMetadata,
hook_name: &str,
args: &[String],
) -> Result<i32> {
let config = Config::load_or_default(None)?;
run_managed_hook_with_state_for_config(hooks_dir, metadata, hook_name, args, &config)
}
fn run_managed_hook_with_state_for_config(
hooks_dir: &Path,
metadata: &GuardInstallMetadata,
hook_name: &str,
args: &[String],
config: &Config,
) -> Result<i32> {
if hook_name == "pre-commit" {
let repo_root = git::get_repo_root()?;
let diff = match git::get_diff(DiffSource::Staged, &repo_root) {
Ok(diff) => diff,
Err(opencodecommit::Error::NoChanges) => String::new(),
Err(err) => return Err(err.into()),
};
if !diff.is_empty() {
let changed_files = context::extract_changed_file_paths(&diff);
let report = scan_diff_for_sensitive_content_with_options(
&diff,
&changed_files,
config.sensitive.enforcement,
&config.sensitive.allowlist,
);
if report.has_blocking_findings() {
if allow_sensitive_bypass_env()
&& policy_allows_sensitive_bypass(config.sensitive.enforcement)
{
return chain_hooks(hooks_dir, metadata, hook_name, args);
}
eprintln!("{}", report.format_git_hook_message());
return Ok(1);
}
if report.has_findings() {
eprintln!("{}", report.format_git_hook_message());
}
}
}
chain_hooks(hooks_dir, metadata, hook_name, args)
}
fn resolve_guard_paths() -> Result<GuardPaths> {
let root = Config::default_config_dir()
.map(|dir| dir.join("guard"))
.ok_or_else(|| {
GuardError::InvalidInstall(
"failed to resolve a config directory for OpenCodeCommit".to_owned(),
)
})?;
Ok(GuardPaths {
hooks_dir: root.join("hooks"),
metadata_path: root.join("install.toml"),
root,
})
}
fn load_metadata() -> Result<GuardInstallMetadata> {
let path = resolve_guard_paths()?.metadata_path;
load_metadata_from(&path)
}
fn load_metadata_from(path: &Path) -> Result<GuardInstallMetadata> {
let content = std::fs::read_to_string(path)?;
toml::from_str(&content).map_err(|err| {
GuardError::InvalidInstall(format!(
"failed to parse guard metadata {}: {err}",
path.display()
))
})
}
fn save_metadata(metadata: &GuardInstallMetadata) -> Result<()> {
let path = resolve_guard_paths()?.metadata_path;
save_metadata_to(&path, metadata)
}
fn save_metadata_to(path: &Path, metadata: &GuardInstallMetadata) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string(metadata).map_err(|err| {
GuardError::InvalidInstall(format!("failed to serialize guard metadata: {err}"))
})?;
std::fs::write(path, content)?;
Ok(())
}
fn write_managed_hook_wrappers(hooks_dir: &Path, occ_path: &Path) -> Result<()> {
let escaped_occ_path = shell_single_quote(occ_path);
let script = format!(
r#"#!/bin/sh
# {marker}
HOOK_NAME=$(basename "$0")
if [ "${{OCC_CHAINED_HOOK_NAME:-}}" = "$HOOK_NAME" ]; then
exit 0
fi
OCC_BIN='{occ_path}'
if [ -x "$OCC_BIN" ]; then
exec "$OCC_BIN" internal run-managed-hook "$HOOK_NAME" "$@"
fi
if command -v occ >/dev/null 2>&1; then
exec occ internal run-managed-hook "$HOOK_NAME" "$@"
fi
if command -v opencodecommit >/dev/null 2>&1; then
exec opencodecommit internal run-managed-hook "$HOOK_NAME" "$@"
fi
echo "OpenCodeCommit guard error: occ executable not found. Reinstall with 'occ guard install --global'." >&2
exit 1
"#,
marker = OCC_MANAGED_HOOK_MARKER,
occ_path = escaped_occ_path,
);
for hook_name in MANAGED_HOOKS {
let hook_path = hooks_dir.join(hook_name);
std::fs::write(&hook_path, &script)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
}
}
Ok(())
}
fn chain_hooks(
hooks_dir: &Path,
metadata: &GuardInstallMetadata,
hook_name: &str,
args: &[String],
) -> Result<i32> {
let mut candidates = Vec::new();
if let Some(previous_global) = metadata.previous_global_hooks_path.as_ref()
&& previous_global != Path::new("/dev/null")
{
candidates.push(previous_global.join(hook_name));
}
if let Some(local_hook) = repo_local_hook_path(hook_name)? {
candidates.push(local_hook);
}
let mut seen = vec![hooks_dir.join(hook_name)];
for candidate in candidates {
if should_run_hook(&candidate, hooks_dir, &seen)? {
let code = run_hook_script(&candidate, hook_name, args)?;
if code != 0 {
return Ok(code);
}
seen.push(candidate);
}
}
Ok(0)
}
fn repo_local_hook_path(hook_name: &str) -> Result<Option<PathBuf>> {
let repo_root = match git::get_repo_root() {
Ok(repo_root) => repo_root,
Err(opencodecommit::Error::Git(_)) => return Ok(None),
Err(err) => return Err(err.into()),
};
Ok(Some(
git::get_git_dir(&repo_root)?.join("hooks").join(hook_name),
))
}
fn should_run_hook(candidate: &Path, managed_hooks_dir: &Path, seen: &[PathBuf]) -> Result<bool> {
if !candidate.exists() || !is_executable(candidate)? {
return Ok(false);
}
if same_path(candidate, managed_hooks_dir) || seen.iter().any(|seen| same_path(candidate, seen))
{
return Ok(false);
}
if is_occ_managed_hook(candidate)? {
return Ok(false);
}
Ok(true)
}
fn is_executable(path: &Path) -> Result<bool> {
let metadata = std::fs::metadata(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
Ok(metadata.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
{
Ok(metadata.is_file())
}
}
fn is_occ_managed_hook(path: &Path) -> Result<bool> {
match std::fs::read_to_string(path) {
Ok(content) => Ok(content.contains(OCC_MANAGED_HOOK_MARKER)),
Err(err) if err.kind() == std::io::ErrorKind::InvalidData => Ok(false),
Err(err) => Err(err.into()),
}
}
fn run_hook_script(path: &Path, hook_name: &str, args: &[String]) -> Result<i32> {
let status = Command::new(path)
.args(args)
.env("OCC_CHAINED_HOOK_NAME", hook_name)
.status()?;
Ok(status.code().unwrap_or(1))
}
fn same_path(a: &Path, b: &Path) -> bool {
match (a.canonicalize(), b.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
fn allow_sensitive_bypass_env() -> bool {
matches!(
std::env::var("OCC_ALLOW_SENSITIVE").ok().as_deref(),
Some("1") | Some("true") | Some("TRUE") | Some("yes") | Some("YES")
)
}
fn shell_single_quote(path: &Path) -> String {
path.to_string_lossy().replace('\'', "'\"'\"'")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
fn setup_repo(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("occ-guard-repo-{}-{}", std::process::id(), name));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let run = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&dir)
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap()
};
run(&["init"]);
run(&["config", "user.email", "test@test.com"]);
run(&["config", "user.name", "Test"]);
fs::write(dir.join("README.md"), "# Hello").unwrap();
run(&["add", "README.md"]);
run(&["commit", "-m", "initial commit"]);
dir
}
fn write_hook_script(path: &Path, line: &str) {
fs::write(
path,
format!("#!/bin/sh\necho {line} >> \"$OCC_TEST_HOOK_LOG\"\n"),
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
}
}
fn test_guard_paths(name: &str) -> GuardPaths {
let root =
std::env::temp_dir().join(format!("occ-guard-test-{}-{}", std::process::id(), name));
let _ = fs::remove_dir_all(&root);
let hooks_dir = root.join("hooks");
fs::create_dir_all(&hooks_dir).unwrap();
GuardPaths {
metadata_path: root.join("install.toml"),
hooks_dir,
root,
}
}
fn with_repo<T>(repo: &Path, f: impl FnOnce() -> T) -> T {
let _lock = opencodecommit::TEST_CWD_LOCK.lock().unwrap();
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(repo).unwrap();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
std::env::set_current_dir(original).unwrap();
match result {
Ok(value) => value,
Err(payload) => std::panic::resume_unwind(payload),
}
}
#[test]
fn writes_managed_hook_wrappers() {
let paths = test_guard_paths("wrappers");
write_managed_hook_wrappers(&paths.hooks_dir, Path::new("/tmp/occ")).unwrap();
let pre_commit = fs::read_to_string(paths.hooks_dir.join("pre-commit")).unwrap();
let commit_msg = fs::read_to_string(paths.hooks_dir.join("commit-msg")).unwrap();
assert!(pre_commit.contains(OCC_MANAGED_HOOK_MARKER));
assert!(pre_commit.contains("internal run-managed-hook"));
assert!(commit_msg.contains(OCC_MANAGED_HOOK_MARKER));
let _ = fs::remove_dir_all(paths.root);
}
#[test]
fn pre_commit_blocks_sensitive_diff_and_bypass_preserves_other_hooks() {
let paths = test_guard_paths("pre-commit");
let repo = setup_repo("pre-commit");
let previous_global = paths.root.join("previous-global-hooks");
fs::create_dir_all(&previous_global).unwrap();
write_hook_script(&previous_global.join("pre-commit"), "global");
let metadata = GuardInstallMetadata {
previous_global_hooks_path: Some(previous_global),
};
let local_hook = repo.join(".git/hooks/pre-commit");
write_hook_script(&local_hook, "local");
let log_path = paths.root.join("hooks.log");
let config = Config {
sensitive: opencodecommit::config::SensitiveConfig {
enforcement: opencodecommit::sensitive::SensitiveEnforcement::BlockHigh,
allowlist: vec![],
},
..Config::default()
};
with_repo(&repo, || {
unsafe {
std::env::remove_var("OCC_ALLOW_SENSITIVE");
std::env::set_var("OCC_TEST_HOOK_LOG", &log_path);
}
fs::write(".env", "API_KEY=secret\n").unwrap();
Command::new("git").args(["add", ".env"]).output().unwrap();
let blocked = run_managed_hook_with_state_for_config(
&paths.hooks_dir,
&metadata,
"pre-commit",
&[],
&config,
)
.unwrap();
assert_eq!(blocked, 1);
assert!(!log_path.exists());
unsafe {
std::env::set_var("OCC_ALLOW_SENSITIVE", "1");
}
let bypassed = run_managed_hook_with_state_for_config(
&paths.hooks_dir,
&metadata,
"pre-commit",
&[],
&config,
)
.unwrap();
assert_eq!(bypassed, 0);
let log = fs::read_to_string(&log_path).unwrap();
assert!(log.contains("global"));
assert!(log.contains("local"));
});
unsafe {
std::env::remove_var("OCC_ALLOW_SENSITIVE");
std::env::remove_var("OCC_TEST_HOOK_LOG");
}
let _ = fs::remove_dir_all(repo);
let _ = fs::remove_dir_all(paths.root);
}
#[test]
fn commit_msg_hook_is_chained() {
let paths = test_guard_paths("commit-msg");
let repo = setup_repo("commit-msg");
let previous_global = paths.root.join("previous-global-hooks");
fs::create_dir_all(&previous_global).unwrap();
write_hook_script(&previous_global.join("commit-msg"), "global-commit-msg");
let metadata = GuardInstallMetadata {
previous_global_hooks_path: Some(previous_global),
};
write_hook_script(&repo.join(".git/hooks/commit-msg"), "local-commit-msg");
let log_path = paths.root.join("commit-msg.log");
with_repo(&repo, || {
unsafe {
std::env::set_var("OCC_TEST_HOOK_LOG", &log_path);
}
let message_file = repo.join(".git/COMMIT_EDITMSG");
fs::write(&message_file, "test").unwrap();
let code = run_managed_hook_with_state(
&paths.hooks_dir,
&metadata,
"commit-msg",
&[message_file.to_string_lossy().to_string()],
)
.unwrap();
assert_eq!(code, 0);
let log = fs::read_to_string(&log_path).unwrap();
assert!(log.contains("global-commit-msg"));
assert!(log.contains("local-commit-msg"));
});
unsafe {
std::env::remove_var("OCC_TEST_HOOK_LOG");
}
let _ = fs::remove_dir_all(repo);
let _ = fs::remove_dir_all(paths.root);
}
}