1pub mod deliver;
7pub mod events;
8pub mod gate;
9pub mod report;
10pub mod trust;
11
12use std::path::{Path, PathBuf};
13
14use serde::Serialize;
15use sha2::{Digest as _, Sha256};
16pub use speccoder_core::hooks::{HookDef, HookFile, HookRun, Point, Role, Runner, TrustState};
17
18pub fn parse(text: &str) -> Result<Vec<HookDef>, String> {
24 let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
25 let Some(hooks) = table.get("hooks") else {
26 return Ok(Vec::new());
27 };
28 let Some(hooks) = hooks.as_table() else {
29 return Err("[hooks] holds one table per hook: [hooks.<name>]".to_owned());
30 };
31 let mut out = hooks
32 .iter()
33 .map(|(name, v)| {
34 let v = serde_json::to_value(v).map_err(|e| e.to_string())?;
35 speccoder_core::hooks::parse(name, &v)
36 })
37 .collect::<Result<Vec<_>, _>>()?;
38 out.sort_by(|a, b| a.name.cmp(&b.name));
39 Ok(out)
40}
41
42pub fn load(root: &Path) -> Result<Vec<HookDef>, String> {
49 let mut hooks = match crate::verify::find_config(root) {
50 Some(dir) => {
51 let file = dir.join(crate::verify::config::FILE);
52 let text =
53 std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
54 parse(&text)?
55 }
56 None => Vec::new(),
57 };
58 for e in crate::extensions::load(root) {
62 if !e.usable() {
63 return Err(format!(
64 "the extension {} cannot be loaded ({}): fix or remove it (speccoder extensions)",
65 e.name,
66 e.problems.join("; ")
67 ));
68 }
69 hooks.extend(e.hooks);
70 }
71 hooks.sort_by(|a, b| a.name.cmp(&b.name));
72 Ok(hooks)
73}
74
75#[must_use]
78pub fn gate_tables(table: &toml::Table) -> serde_json::Value {
79 let mut out = serde_json::Map::new();
80 if let Some(hooks) = table.get("hooks").and_then(toml::Value::as_table) {
81 for (name, t) in hooks {
82 if t.get("at").is_some()
83 && let Ok(v) = serde_json::to_value(t)
84 {
85 out.insert(name.clone(), v);
86 }
87 }
88 }
89 if out.is_empty() {
90 serde_json::Value::Null
91 } else {
92 speccoder_core::hooks::canonical(&serde_json::Value::Object(out))
93 }
94}
95
96#[derive(Debug, Clone)]
98pub struct Digested {
99 pub def: HookDef,
101 pub digest: String,
103 pub files: Vec<HookFile>,
105 pub outside: Vec<String>,
108 pub trust_key: String,
111}
112
113impl Digested {
114 #[must_use]
116 pub fn trust(&self, dir: &Path) -> TrustState {
117 trust::state(dir, &self.trust_key, &self.digest)
118 }
119}
120
121fn sha256_hex(bytes: &[u8]) -> String {
122 Sha256::digest(bytes)
123 .iter()
124 .map(|b| format!("{b:02x}"))
125 .collect()
126}
127
128#[must_use]
130pub fn digest(def: &HookDef, root: &Path) -> Digested {
131 if let Some((extension, folder)) = &def.extension {
132 let (digest, files) = crate::extensions::digest(Path::new(folder))
133 .unwrap_or_else(|e| (format!("unreadable: {e}"), Vec::new()));
134 return Digested {
135 def: def.clone(),
136 digest,
137 files,
138 outside: Vec::new(),
139 trust_key: crate::extensions::trust::key(extension),
140 };
141 }
142 let canonical_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
143 let mut files = Vec::new();
144 let mut outside = Vec::new();
145 for f in &def.files {
148 let content = std::fs::read(canonical_root.join(f)).ok();
149 files.push(HookFile {
150 path: f.clone(),
151 digest: content.map_or_else(
152 || "missing".to_owned(),
153 |c| format!("sha256:{}", sha256_hex(&c)),
154 ),
155 });
156 }
157 for arg in def.argv().unwrap_or_default() {
158 let candidate = Path::new(arg);
159 let full = if candidate.is_absolute() {
160 candidate.to_path_buf()
161 } else {
162 canonical_root.join(candidate)
163 };
164 let Ok(resolved) = std::fs::canonicalize(&full) else {
165 continue;
166 };
167 if !resolved.is_file() {
168 continue;
169 }
170 match resolved.strip_prefix(&canonical_root) {
171 Ok(rel) => {
172 let content = std::fs::read(&resolved).unwrap_or_default();
173 let path = rel.to_string_lossy().replace('\\', "/");
174 if !files.iter().any(|f: &HookFile| f.path == path) {
175 files.push(HookFile {
176 path,
177 digest: format!("sha256:{}", sha256_hex(&content)),
178 });
179 }
180 }
181 Err(_) => outside.push(arg.clone()),
183 }
184 }
185 let mut bytes = def.definition.to_string().into_bytes();
186 for f in &files {
187 bytes.push(0);
188 bytes.extend_from_slice(f.path.as_bytes());
189 bytes.push(0);
190 bytes.extend_from_slice(f.digest.as_bytes());
191 }
192 Digested {
193 def: def.clone(),
194 digest: format!("sha256:{}", sha256_hex(&bytes)),
195 files,
196 outside,
197 trust_key: def.name.clone(),
198 }
199}
200
201#[must_use]
204pub fn judge_files(project: &Path, wt_root: &Path) -> Vec<String> {
205 let prefix = std::fs::canonicalize(project)
206 .ok()
207 .zip(std::fs::canonicalize(wt_root).ok())
208 .and_then(|(p, w)| p.strip_prefix(&w).ok().map(Path::to_path_buf))
209 .unwrap_or_default();
210 load(project)
212 .unwrap_or_default()
213 .iter()
214 .filter(|d| d.extension.is_none())
215 .flat_map(|d| digest(d, project).files)
216 .map(|f| prefix.join(&f.path).to_string_lossy().replace('\\', "/"))
217 .collect()
218}
219
220#[must_use]
223pub fn dir(state: &Path, root: &Path) -> PathBuf {
224 crate::worktree::project_state_dir(state, root).join("hooks")
225}
226
227#[must_use]
229pub fn project_id(state: &Path, root: &Path) -> String {
230 crate::worktree::project_state_dir(state, root)
231 .file_name()
232 .map(|n| n.to_string_lossy().into_owned())
233 .unwrap_or_default()
234}
235
236pub const RECENT: usize = 20;
238
239const RUNS: &str = "runs.json";
240
241#[must_use]
243pub fn runs(dir: &Path) -> std::collections::BTreeMap<String, Vec<HookRun>> {
244 std::fs::read_to_string(dir.join(RUNS))
245 .ok()
246 .and_then(|t| serde_json::from_str(&t).ok())
247 .unwrap_or_default()
248}
249
250pub fn record_run(dir: &Path, hook: &str, run: HookRun) {
252 let mut all = runs(dir);
253 let list = all.entry(hook.to_owned()).or_default();
254 list.push(run);
255 let excess = list.len().saturating_sub(RECENT);
256 list.drain(..excess);
257 write_json(dir, RUNS, &all);
258}
259
260pub(crate) fn write_json(dir: &Path, name: &str, value: &impl Serialize) {
261 let _ = std::fs::create_dir_all(dir);
262 if let Ok(text) = serde_json::to_string_pretty(value) {
263 let tmp = dir.join(format!(".{name}.tmp"));
264 if std::fs::write(&tmp, text).is_ok() {
265 let _ = std::fs::rename(&tmp, dir.join(name));
266 }
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 const TOML: &str = r#"
275[hooks.licence]
276at = "tick"
277argv = ["sh", "scripts/licence.sh"]
278
279[hooks.chat]
280on = ["*"]
281url = "https://hooks.example.com/x"
282"#;
283
284 #[test]
285 fn hooks_are_read_by_name_and_a_bad_one_is_an_error() {
286 let hooks = parse(TOML).unwrap();
287 assert_eq!(
288 hooks.iter().map(|h| h.name.as_str()).collect::<Vec<_>>(),
289 ["chat", "licence"]
290 );
291 assert!(parse("[hooks]\nx = 1\n").is_err());
292 assert!(parse("").unwrap().is_empty());
293 }
294
295 #[test]
296 fn a_byte_of_the_definition_or_its_script_changes_the_digest() {
297 let dir = tempfile::tempdir().unwrap();
298 std::fs::create_dir_all(dir.path().join("scripts")).unwrap();
299 std::fs::write(dir.path().join("scripts/licence.sh"), "exit 0\n").unwrap();
300 let hooks = parse(TOML).unwrap();
301 let gate = &hooks[1];
302 let first = digest(gate, dir.path());
303 assert_eq!(first.files.len(), 1);
304 assert_eq!(first.files[0].path, "scripts/licence.sh");
305 assert_eq!(digest(gate, dir.path()).digest, first.digest);
306 std::fs::write(dir.path().join("scripts/licence.sh"), "exit 1\n").unwrap();
307 assert_ne!(digest(gate, dir.path()).digest, first.digest);
308 let reordered = parse(
309 "[hooks.licence]\nargv = [\"sh\", \"scripts/licence.sh\"]\n# a comment\nat = \"tick\"\n",
310 )
311 .unwrap();
312 assert_eq!(
313 digest(&reordered[0], dir.path()).digest,
314 digest(gate, dir.path()).digest
315 );
316 let longer =
317 parse("[hooks.licence]\nat = \"tick\"\nargv = [\"sh\", \"scripts/licence.sh\"]\ntimeout = 61\n")
318 .unwrap();
319 assert_ne!(
320 digest(&longer[0], dir.path()).digest,
321 digest(gate, dir.path()).digest
322 );
323 }
324
325 #[test]
326 fn only_gates_join_the_judges_configuration() {
327 let table: toml::Table = toml::from_str(TOML).unwrap();
328 let v = gate_tables(&table);
329 assert!(v.get("licence").is_some() && v.get("chat").is_none());
330 let moved: toml::Table =
331 toml::from_str(&TOML.replace("hooks.example.com", "other.example.com")).unwrap();
332 assert_eq!(gate_tables(&moved), v);
333 }
334
335 #[test]
336 fn a_hook_keeps_its_last_twenty_runs() {
337 let dir = tempfile::tempdir().unwrap();
338 for i in 0..25 {
339 record_run(
340 dir.path(),
341 "licence",
342 HookRun {
343 at: format!("{i}"),
344 what: "test".into(),
345 outcome: "passed".into(),
346 duration_ms: 1,
347 error: None,
348 findings: Vec::new(),
349 findings_error: None,
350 },
351 );
352 }
353 let r = &runs(dir.path())["licence"];
354 assert_eq!(r.len(), RECENT);
355 assert_eq!(r[0].at, "5");
356 }
357}