use anyhow::{anyhow, Context, Result};
use std::path::PathBuf;
use std::process::Command;
use crate::cli::AuditArgs;
use crate::storage::resolve_path;
#[derive(serde::Serialize)]
struct Entry {
commit: String,
date: String,
author: String,
signature_status: String,
signer: String,
subject: String,
}
#[allow(dead_code)]
fn _actor_kind_marker() {}
pub fn run(args: AuditArgs, file: &Option<PathBuf>) -> Result<()> {
let path = resolve_path(file);
if !path.exists() {
return Err(anyhow!(
"{} does not exist — run `req init` first",
path.display()
));
}
let fmt = "%H|||%aI|||%aN|||%G?|||%GS|||%s";
let output = Command::new("git")
.args([
"log",
"--follow",
&format!("-n{}", args.limit),
&format!("--format={}", fmt),
"--",
])
.arg(&path)
.output()
.context("run git log")?;
if !output.status.success() {
return Err(anyhow!(
"git log failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let text = String::from_utf8_lossy(&output.stdout);
let mut entries: Vec<Entry> = Vec::new();
for line in text.lines() {
let parts: Vec<&str> = line.splitn(6, "|||").collect();
if parts.len() != 6 {
continue;
}
entries.push(Entry {
commit: parts[0].into(),
date: parts[1].into(),
author: parts[2].into(),
signature_status: explain_sig(parts[3]),
signer: parts[4].into(),
subject: parts[5].into(),
});
}
if entries.is_empty() {
println!("No git history found for {}.", path.display());
return Ok(());
}
let mut violations: Vec<serde_json::Value> = Vec::new();
if args.gate {
for e in &entries {
let mut why: Vec<String> = Vec::new();
if args.require_good_signature {
let ok = matches!(e.signature_status.as_str(), "good" | "good-unknown");
if !ok {
why.push(format!(
"signature status '{}' is not 'good'",
e.signature_status
));
}
}
if !args.required_signers.is_empty() {
let signer_lc = e.signer.to_lowercase();
let matched = args
.required_signers
.iter()
.any(|s| signer_lc.contains(&s.to_lowercase()));
if !matched {
why.push(format!(
"signer '{}' is not in --require-signer list",
if e.signer.is_empty() {
"<none>"
} else {
&e.signer
}
));
}
}
if !why.is_empty() {
violations.push(serde_json::json!({
"commit": e.commit, "signer": e.signer,
"signature_status": e.signature_status,
"subject": e.subject,
"why": why,
}));
}
}
}
if args.json {
if args.gate {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"ok": violations.is_empty(),
"entries": entries,
"violations": violations,
}))?
);
if !violations.is_empty() {
std::process::exit(1);
}
} else {
println!("{}", serde_json::to_string_pretty(&entries)?);
}
return Ok(());
}
let unsigned = entries
.iter()
.filter(|e| e.signature_status == "no-signature")
.count();
let bad = entries
.iter()
.filter(|e| matches!(e.signature_status.as_str(), "bad" | "expired" | "revoked"))
.count();
println!("Audit of {} ({} commit(s))", path.display(), entries.len());
println!(" signed : {}", entries.len() - unsigned - bad);
println!(" unsigned : {}", unsigned);
println!(" problem : {}", bad);
println!();
println!(
"{:<10} {:<20} {:<18} {:<14} subject",
"commit", "date", "author", "signature"
);
for e in &entries {
let short = &e.commit[..e.commit.len().min(9)];
let signer = if e.signer.is_empty() {
"-".into()
} else {
e.signer.clone()
};
println!(
"{:<10} {:<20} {:<18} {:<14} {}",
short,
&e.date[..e.date.len().min(19)],
truncate(&e.author, 18),
format!("{} {}", e.signature_status, truncate(&signer, 8)),
e.subject,
);
}
if args.gate {
println!();
if violations.is_empty() {
println!(
"req audit --gate: all {} commit(s) pass policy.",
entries.len()
);
} else {
println!("req audit --gate: {} violation(s):", violations.len());
for v in &violations {
println!(" {} — {}", v["commit"].as_str().unwrap_or("?"), v["why"]);
}
std::process::exit(1);
}
}
Ok(())
}
fn explain_sig(code: &str) -> String {
match code {
"G" => "good",
"B" => "bad",
"U" => "good-unknown",
"X" => "expired",
"Y" => "expired-key",
"R" => "revoked",
"E" => "cannot-check",
"N" | "" => "no-signature",
_ => "unknown",
}
.to_string()
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
s.to_string()
} else {
let mut out: String = s.chars().take(n - 1).collect();
out.push('…');
out
}
}