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 {
59 let Some(path) = default_approval_path().ok() else {
60 return Self::empty();
61 };
62 let entries = approval_read_path()
63 .ok()
64 .filter(|p| p.exists())
65 .and_then(|p| std::fs::read_to_string(p).ok())
66 .and_then(|s| toml::from_str::<ApprovalFile>(&s).ok())
67 .map(|f| f.entries)
68 .unwrap_or_default();
69 Self { path, entries }
70 }
71
72 fn empty() -> Self {
73 Self {
74 path: PathBuf::new(),
75 entries: HashMap::new(),
76 }
77 }
78
79 pub fn is_approved(&self, repo_path: &Path, settings_hash: &str) -> bool {
82 self.entries
83 .get(&canonical_key(repo_path))
84 .is_some_and(|e| e.settings_hash == settings_hash)
85 }
86
87 pub fn approve(&mut self, repo_path: &Path, settings_hash: &str) {
90 self.entries.insert(
91 canonical_key(repo_path),
92 HookApprovalEntry {
93 settings_hash: settings_hash.to_string(),
94 approved_at: Utc::now(),
95 },
96 );
97 }
98
99 pub fn persist(&self) -> io::Result<()> {
102 if self.path.as_os_str().is_empty() {
103 return Ok(());
104 }
105 if let Some(parent) = self.path.parent() {
106 std::fs::create_dir_all(parent)?;
107 }
108 let file = ApprovalFile {
109 entries: self.entries.clone(),
110 };
111 let body = toml::to_string_pretty(&file).map_err(io::Error::other)?;
112 let tmp = self.path.with_extension("toml.tmp");
113 std::fs::write(&tmp, body)?;
114 std::fs::rename(&tmp, &self.path)?;
115 Ok(())
116 }
117}
118
119pub fn hash_settings(content: &str) -> String {
121 let mut h = Sha256::new();
122 h.update(content.as_bytes());
123 let digest = h.finalize();
124 let mut out = String::with_capacity(64);
125 for b in digest {
126 use std::fmt::Write as _;
127 let _ = write!(&mut out, "{:02x}", b);
128 }
129 out
130}
131
132fn default_approval_path() -> io::Result<PathBuf> {
133 oxicode_catalog::oxi_home::oxicode_home()
134 .map(|h| h.join(APPROVAL_FILENAME))
135 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "oxicode home not found"))
136}
137
138fn approval_read_path() -> io::Result<PathBuf> {
141 oxicode_catalog::oxi_home::read_path(Path::new(APPROVAL_FILENAME))
142 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "oxicode home not found"))
143}
144
145fn canonical_key(p: &Path) -> String {
146 std::fs::canonicalize(p)
147 .unwrap_or_else(|_| p.to_path_buf())
148 .to_string_lossy()
149 .into_owned()
150}
151
152pub fn prompt_for_approval(repo_path: &Path, hook_count: usize) -> bool {
156 eprintln!();
157 eprintln!(
158 "Project at {} wants to run {} hook(s) defined in `.oxicode/settings.toml`.",
159 repo_path.display(),
160 hook_count
161 );
162 eprintln!("Allow? [y/N]");
163 eprint!("> ");
164 let _ = io::stderr().flush();
165 let mut line = String::new();
166 if io::stdin().read_line(&mut line).is_err() {
167 return false;
168 }
169 matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use tempfile::TempDir;
176
177 #[test]
178 fn hash_is_deterministic_and_hex() {
179 let h1 = hash_settings("hello");
180 let h2 = hash_settings("hello");
181 assert_eq!(h1, h2);
182 assert_eq!(h1.len(), 64);
183 assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
184 }
185
186 #[test]
187 fn empty_registry_approves_nothing() {
188 let r = HookApprovalRegistry::load_or_default();
189 assert!(!r.is_approved(Path::new("/tmp/nope"), "abc"));
190 }
191
192 #[test]
193 fn approve_then_check_round_trip() {
194 let tmp = TempDir::new().unwrap();
195 let p = tmp.path().join("hooks_approved.toml");
196 let mut r = HookApprovalRegistry {
197 path: p.clone(),
198 entries: HashMap::new(),
199 };
200 let repo = tmp.path().join("repo");
201 std::fs::create_dir_all(&repo).unwrap();
202 r.approve(&repo, "deadbeef");
203 r.persist().unwrap();
204 assert!(p.exists());
205
206 let text = std::fs::read_to_string(&p).unwrap();
208 let file: ApprovalFile = toml::from_str(&text).unwrap();
209 let r2 = HookApprovalRegistry {
210 path: p,
211 entries: file.entries,
212 };
213 assert!(r2.is_approved(&repo, "deadbeef"));
214 assert!(!r2.is_approved(&repo, "f0000000"));
215 }
216
217 #[test]
218 fn hash_mismatch_revokes_approval() {
219 let tmp = TempDir::new().unwrap();
220 let repo = tmp.path().join("r");
221 std::fs::create_dir_all(&repo).unwrap();
222 let mut r = HookApprovalRegistry {
223 path: tmp.path().join("f.toml"),
224 entries: HashMap::new(),
225 };
226 r.approve(&repo, "v1");
227 assert!(r.is_approved(&repo, "v1"));
228 assert!(!r.is_approved(&repo, "v2"));
230 }
231}