Skip to main content

speccoder_runtime/hooks/
mod.rs

1//! The hooks of a project: the `[hooks]` of `speccoder.toml`, each hook's
2//! digest, whether a person trusted it, and its recent runs. Gates run in
3//! [`gate`]; events are projected in [`events`] and delivered in
4//! [`deliver`]. Nothing here runs before a person trusted the exact bytes.
5
6pub 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
18/// Every `[hooks.<name>]` of the configuration `text`, by name.
19///
20/// # Errors
21///
22/// A `HOOK-MISCONFIGURED` message naming the hook and the fix.
23pub 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
42/// The hooks of the project at `root`: its `[hooks]` and its extensions'.
43///
44/// # Errors
45///
46/// The configuration cannot be read, a hook is misconfigured, or an
47/// extension cannot be loaded.
48pub 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    // An extension's hooks, trusted or not: an untrusted gate blocks. One
59    // that cannot be loaded blocks every point, as a broken `[hooks]` table
60    // does — its gates must not quietly fall away.
61    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/// The hooks of a configuration's gate tables, as the judge's configuration
76/// hashes them: an observer's change does not move the hash.
77#[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/// A hook with the digest of what it would run here.
97#[derive(Debug, Clone)]
98pub struct Digested {
99    /// The definition.
100    pub def: HookDef,
101    /// `sha256:<hex>` over the definition and its files.
102    pub digest: String,
103    /// The repository files its `argv` names.
104    pub files: Vec<HookFile>,
105    /// Programs it names outside the repository (named in the trust prompt,
106    /// not digested).
107    pub outside: Vec<String>,
108    /// What its trust is recorded under: its name, or `extension:<name>`
109    /// for an extension's hook (whose digest is the extension's).
110    pub trust_key: String,
111}
112
113impl Digested {
114    /// Whether it may run, against the records in `dir`.
115    #[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/// `def`'s digest with the files it runs as they are under `root`.
129#[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    // Each declared file counts, present or not: one that appears later
146    // changes the digest too.
147    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            // Absolute, or a link out of the repository: named, not digested.
182            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/// The files every hook of the project at `project` runs, relative to the
202/// worktree `wt_root`: judge files, which the model may not write.
203#[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    // An extension's files are protected by its folder (`tools::paths`).
211    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/// Where hooks keep their state for the project at `root`, under
221/// speccoder's state directory `state`.
222#[must_use]
223pub fn dir(state: &Path, root: &Path) -> PathBuf {
224    crate::worktree::project_state_dir(state, root).join("hooks")
225}
226
227/// The project's id, as an event's `source` names it.
228#[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
236/// How many runs each hook keeps.
237pub const RECENT: usize = 20;
238
239const RUNS: &str = "runs.json";
240
241/// Every hook's recent runs, oldest first.
242#[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
250/// Adds `run` to `hook`'s recent runs, keeping the last [`RECENT`].
251pub 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}