use anyhow::{Context, Result};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use sysinfo::{Pid, System};
const BYPASS_ENV: &str = "DID_GIT_SIGN_BYPASS_POLICY";
const ALLOWED_PARENTS: &[&str] = &["git", "ssh-keygen"];
#[derive(Debug, Clone, Serialize)]
pub struct AuditEntry {
pub timestamp_utc: String,
pub action: &'static str,
pub allowed: bool,
pub parent_pid: Option<u32>,
pub parent_name: Option<String>,
pub namespace: String,
pub buffer_path: Option<String>,
pub buffer_sha256: String,
pub bypass: bool,
}
pub fn evaluate(
namespace: &str,
buffer_path: Option<&std::path::Path>,
buffer: &[u8],
) -> AuditEntry {
let bypass =
std::env::var(BYPASS_ENV).is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
let (parent_pid, parent_name) = parent_process_info();
let parent_token = parent_name
.as_deref()
.and_then(|n| n.split_whitespace().next())
.map(|s| s.to_lowercase());
let parent_ok = parent_token
.as_deref()
.map(|tok| {
ALLOWED_PARENTS
.iter()
.any(|allowed| tok.starts_with(allowed))
})
.unwrap_or(false);
let allowed = bypass || parent_ok;
let mut hasher = Sha256::new();
hasher.update(buffer);
let buffer_sha256 = hex::encode(hasher.finalize());
AuditEntry {
timestamp_utc: chrono::Utc::now().to_rfc3339(),
action: "sign",
allowed,
parent_pid,
parent_name,
namespace: namespace.to_string(),
buffer_path: buffer_path.map(|p| p.display().to_string()),
buffer_sha256,
bypass,
}
}
pub fn write_audit(entry: &AuditEntry) {
if let Err(e) = try_write_audit(entry) {
tracing::warn!("did-git-sign audit log write failed: {e}");
}
}
fn try_write_audit(entry: &AuditEntry) -> Result<()> {
let path = audit_log_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create audit dir {}", parent.display()))?;
}
let line = serde_json::to_string(entry)?;
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
use std::io::Write as _;
let mut f = opts
.open(&path)
.with_context(|| format!("open audit log {}", path.display()))?;
writeln!(f, "{line}").with_context(|| format!("write audit log {}", path.display()))?;
Ok(())
}
pub fn audit_log_path() -> Result<PathBuf> {
let dir = dirs::config_dir().context("could not determine config directory")?;
Ok(dir.join("did-git-sign").join("audit.log"))
}
fn parent_process_info() -> (Option<u32>, Option<String>) {
let mut sys = System::new();
let self_pid = std::process::id();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, false);
let parent_pid = sys
.process(Pid::from_u32(self_pid))
.and_then(|p| p.parent())
.map(|p| p.as_u32());
let parent_name = parent_pid.and_then(|pid| {
sys.process(Pid::from_u32(pid))
.map(|p| p.name().to_string_lossy().to_string())
});
(parent_pid, parent_name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn audit_entry_is_json_serializable() {
let entry = AuditEntry {
timestamp_utc: "2026-05-05T00:00:00Z".to_string(),
action: "sign",
allowed: true,
parent_pid: Some(123),
parent_name: Some("git".to_string()),
namespace: "git".to_string(),
buffer_path: Some("/tmp/buffer".to_string()),
buffer_sha256: "deadbeef".to_string(),
bypass: false,
};
let s = serde_json::to_string(&entry).unwrap();
assert!(s.contains("\"allowed\":true"));
assert!(s.contains("\"parent_name\":\"git\""));
}
#[test]
fn evaluate_records_buffer_hash() {
let entry = evaluate("git", None, b"hello");
assert_eq!(
entry.buffer_sha256,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[test]
fn evaluate_bypass_env_allows_unknown_parent() {
unsafe { std::env::set_var(BYPASS_ENV, "1") };
let entry = evaluate("git", None, b"x");
unsafe { std::env::remove_var(BYPASS_ENV) };
assert!(entry.allowed);
assert!(entry.bypass);
}
}