oxicode/store/
hook_approval.rs1use std::collections::HashMap;
15use std::io::{self, Write};
16use std::path::{Path, PathBuf};
17
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21
22const APPROVAL_FILENAME: &str = "hooks_approved.toml";
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct HookApprovalEntry {
26 pub settings_hash: String,
28 pub approved_at: DateTime<Utc>,
30}
31
32#[derive(Debug, Default, Clone, Serialize, Deserialize)]
33struct ApprovalFile {
34 #[serde(default)]
36 entries: HashMap<String, HookApprovalEntry>,
37}
38
39pub struct HookApprovalRegistry {
40 path: PathBuf,
41 entries: HashMap<String, HookApprovalEntry>,
42}
43
44impl std::fmt::Debug for HookApprovalRegistry {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.debug_struct("HookApprovalRegistry")
47 .field("path", &self.path)
48 .field("entry_count", &self.entries.len())
49 .finish()
50 }
51}
52
53impl HookApprovalRegistry {
54 pub fn load_or_default() -> Self {
57 let path = match default_approval_path() {
58 Ok(p) => p,
59 Err(_) => return Self::empty(),
60 };
61 let entries = std::fs::read_to_string(&path)
62 .ok()
63 .and_then(|s| toml::from_str::<ApprovalFile>(&s).ok())
64 .map(|f| f.entries)
65 .unwrap_or_default();
66 Self { path, entries }
67 }
68
69 fn empty() -> Self {
70 Self {
71 path: PathBuf::new(),
72 entries: HashMap::new(),
73 }
74 }
75
76 pub fn is_approved(&self, repo_path: &Path, settings_hash: &str) -> bool {
79 self.entries
80 .get(&canonical_key(repo_path))
81 .is_some_and(|e| e.settings_hash == settings_hash)
82 }
83
84 pub fn approve(&mut self, repo_path: &Path, settings_hash: &str) {
87 self.entries.insert(
88 canonical_key(repo_path),
89 HookApprovalEntry {
90 settings_hash: settings_hash.to_string(),
91 approved_at: Utc::now(),
92 },
93 );
94 }
95
96 pub fn persist(&self) -> io::Result<()> {
99 if self.path.as_os_str().is_empty() {
100 return Ok(());
101 }
102 if let Some(parent) = self.path.parent() {
103 std::fs::create_dir_all(parent)?;
104 }
105 let file = ApprovalFile {
106 entries: self.entries.clone(),
107 };
108 let body = toml::to_string_pretty(&file).map_err(io::Error::other)?;
109 let tmp = self.path.with_extension("toml.tmp");
110 std::fs::write(&tmp, body)?;
111 std::fs::rename(&tmp, &self.path)?;
112 Ok(())
113 }
114}
115
116pub fn hash_settings(content: &str) -> String {
118 let mut h = Sha256::new();
119 h.update(content.as_bytes());
120 let digest = h.finalize();
121 let mut out = String::with_capacity(64);
122 for b in digest {
123 use std::fmt::Write as _;
124 let _ = write!(&mut out, "{:02x}", b);
125 }
126 out
127}
128
129fn default_approval_path() -> io::Result<PathBuf> {
130 let home = dirs::home_dir()
131 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "home dir not found"))?;
132 Ok(home.join(".oxicode").join(APPROVAL_FILENAME))
133}
134
135fn canonical_key(p: &Path) -> String {
136 std::fs::canonicalize(p)
137 .unwrap_or_else(|_| p.to_path_buf())
138 .to_string_lossy()
139 .into_owned()
140}
141
142pub fn prompt_for_approval(repo_path: &Path, hook_count: usize) -> bool {
146 eprintln!();
147 eprintln!(
148 "Project at {} wants to run {} hook(s) defined in `.oxicode/settings.toml`.",
149 repo_path.display(),
150 hook_count
151 );
152 eprintln!("Allow? [y/N]");
153 eprint!("> ");
154 let _ = io::stderr().flush();
155 let mut line = String::new();
156 if io::stdin().read_line(&mut line).is_err() {
157 return false;
158 }
159 matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use tempfile::TempDir;
166
167 #[test]
168 fn hash_is_deterministic_and_hex() {
169 let h1 = hash_settings("hello");
170 let h2 = hash_settings("hello");
171 assert_eq!(h1, h2);
172 assert_eq!(h1.len(), 64);
173 assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
174 }
175
176 #[test]
177 fn empty_registry_approves_nothing() {
178 let r = HookApprovalRegistry::load_or_default();
179 assert!(!r.is_approved(Path::new("/tmp/nope"), "abc"));
180 }
181
182 #[test]
183 fn approve_then_check_round_trip() {
184 let tmp = TempDir::new().unwrap();
185 let p = tmp.path().join("hooks_approved.toml");
186 let mut r = HookApprovalRegistry {
187 path: p.clone(),
188 entries: HashMap::new(),
189 };
190 let repo = tmp.path().join("repo");
191 std::fs::create_dir_all(&repo).unwrap();
192 r.approve(&repo, "deadbeef");
193 r.persist().unwrap();
194 assert!(p.exists());
195
196 let text = std::fs::read_to_string(&p).unwrap();
198 let file: ApprovalFile = toml::from_str(&text).unwrap();
199 let r2 = HookApprovalRegistry {
200 path: p,
201 entries: file.entries,
202 };
203 assert!(r2.is_approved(&repo, "deadbeef"));
204 assert!(!r2.is_approved(&repo, "f0000000"));
205 }
206
207 #[test]
208 fn hash_mismatch_revokes_approval() {
209 let tmp = TempDir::new().unwrap();
210 let repo = tmp.path().join("r");
211 std::fs::create_dir_all(&repo).unwrap();
212 let mut r = HookApprovalRegistry {
213 path: tmp.path().join("f.toml"),
214 entries: HashMap::new(),
215 };
216 r.approve(&repo, "v1");
217 assert!(r.is_approved(&repo, "v1"));
218 assert!(!r.is_approved(&repo, "v2"));
220 }
221}