use std::collections::HashMap;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
const APPROVAL_FILENAME: &str = "hooks_approved.toml";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HookApprovalEntry {
pub settings_hash: String,
pub approved_at: DateTime<Utc>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct ApprovalFile {
#[serde(default)]
entries: HashMap<String, HookApprovalEntry>,
}
pub struct HookApprovalRegistry {
path: PathBuf,
entries: HashMap<String, HookApprovalEntry>,
}
impl std::fmt::Debug for HookApprovalRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HookApprovalRegistry")
.field("path", &self.path)
.field("entry_count", &self.entries.len())
.finish()
}
}
impl HookApprovalRegistry {
pub fn load_or_default() -> Self {
let path = match default_approval_path() {
Ok(p) => p,
Err(_) => return Self::empty(),
};
let entries = std::fs::read_to_string(&path)
.ok()
.and_then(|s| toml::from_str::<ApprovalFile>(&s).ok())
.map(|f| f.entries)
.unwrap_or_default();
Self { path, entries }
}
fn empty() -> Self {
Self {
path: PathBuf::new(),
entries: HashMap::new(),
}
}
pub fn is_approved(&self, repo_path: &Path, settings_hash: &str) -> bool {
self.entries
.get(&canonical_key(repo_path))
.is_some_and(|e| e.settings_hash == settings_hash)
}
pub fn approve(&mut self, repo_path: &Path, settings_hash: &str) {
self.entries.insert(
canonical_key(repo_path),
HookApprovalEntry {
settings_hash: settings_hash.to_string(),
approved_at: Utc::now(),
},
);
}
pub fn persist(&self) -> io::Result<()> {
if self.path.as_os_str().is_empty() {
return Ok(());
}
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = ApprovalFile {
entries: self.entries.clone(),
};
let body = toml::to_string_pretty(&file).map_err(io::Error::other)?;
let tmp = self.path.with_extension("toml.tmp");
std::fs::write(&tmp, body)?;
std::fs::rename(&tmp, &self.path)?;
Ok(())
}
}
pub fn hash_settings(content: &str) -> String {
let mut h = Sha256::new();
h.update(content.as_bytes());
let digest = h.finalize();
let mut out = String::with_capacity(64);
for b in digest {
use std::fmt::Write as _;
let _ = write!(&mut out, "{:02x}", b);
}
out
}
fn default_approval_path() -> io::Result<PathBuf> {
let home = dirs::home_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "home dir not found"))?;
Ok(home.join(".oxicode").join(APPROVAL_FILENAME))
}
fn canonical_key(p: &Path) -> String {
std::fs::canonicalize(p)
.unwrap_or_else(|_| p.to_path_buf())
.to_string_lossy()
.into_owned()
}
pub fn prompt_for_approval(repo_path: &Path, hook_count: usize) -> bool {
eprintln!();
eprintln!(
"Project at {} wants to run {} hook(s) defined in `.oxicode/settings.toml`.",
repo_path.display(),
hook_count
);
eprintln!("Allow? [y/N]");
eprint!("> ");
let _ = io::stderr().flush();
let mut line = String::new();
if io::stdin().read_line(&mut line).is_err() {
return false;
}
matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn hash_is_deterministic_and_hex() {
let h1 = hash_settings("hello");
let h2 = hash_settings("hello");
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64);
assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn empty_registry_approves_nothing() {
let r = HookApprovalRegistry::load_or_default();
assert!(!r.is_approved(Path::new("/tmp/nope"), "abc"));
}
#[test]
fn approve_then_check_round_trip() {
let tmp = TempDir::new().unwrap();
let p = tmp.path().join("hooks_approved.toml");
let mut r = HookApprovalRegistry {
path: p.clone(),
entries: HashMap::new(),
};
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
r.approve(&repo, "deadbeef");
r.persist().unwrap();
assert!(p.exists());
let text = std::fs::read_to_string(&p).unwrap();
let file: ApprovalFile = toml::from_str(&text).unwrap();
let r2 = HookApprovalRegistry {
path: p,
entries: file.entries,
};
assert!(r2.is_approved(&repo, "deadbeef"));
assert!(!r2.is_approved(&repo, "f0000000"));
}
#[test]
fn hash_mismatch_revokes_approval() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("r");
std::fs::create_dir_all(&repo).unwrap();
let mut r = HookApprovalRegistry {
path: tmp.path().join("f.toml"),
entries: HashMap::new(),
};
r.approve(&repo, "v1");
assert!(r.is_approved(&repo, "v1"));
assert!(!r.is_approved(&repo, "v2"));
}
}