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` (none without a configuration).
43///
44/// # Errors
45///
46/// The configuration cannot be read, or a hook is misconfigured.
47pub fn load(root: &Path) -> Result<Vec<HookDef>, String> {
48    let Some(dir) = crate::verify::find_config(root) else {
49        return Ok(Vec::new());
50    };
51    let file = dir.join(crate::verify::config::FILE);
52    let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
53    parse(&text)
54}
55
56/// The hooks of a configuration's gate tables, as the judge's configuration
57/// hashes them: an observer's change does not move the hash.
58#[must_use]
59pub fn gate_tables(table: &toml::Table) -> serde_json::Value {
60    let mut out = serde_json::Map::new();
61    if let Some(hooks) = table.get("hooks").and_then(toml::Value::as_table) {
62        for (name, t) in hooks {
63            if t.get("at").is_some()
64                && let Ok(v) = serde_json::to_value(t)
65            {
66                out.insert(name.clone(), v);
67            }
68        }
69    }
70    if out.is_empty() {
71        serde_json::Value::Null
72    } else {
73        speccoder_core::hooks::canonical(&serde_json::Value::Object(out))
74    }
75}
76
77/// A hook with the digest of what it would run here.
78#[derive(Debug, Clone)]
79pub struct Digested {
80    /// The definition.
81    pub def: HookDef,
82    /// `sha256:<hex>` over the definition and its files.
83    pub digest: String,
84    /// The repository files its `argv` names.
85    pub files: Vec<HookFile>,
86    /// Programs it names outside the repository (named in the trust prompt,
87    /// not digested).
88    pub outside: Vec<String>,
89}
90
91fn sha256_hex(bytes: &[u8]) -> String {
92    Sha256::digest(bytes)
93        .iter()
94        .map(|b| format!("{b:02x}"))
95        .collect()
96}
97
98/// `def`'s digest with the files it runs as they are under `root`.
99#[must_use]
100pub fn digest(def: &HookDef, root: &Path) -> Digested {
101    let canonical_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
102    let mut files = Vec::new();
103    let mut outside = Vec::new();
104    // Each declared file counts, present or not: one that appears later
105    // changes the digest too.
106    for f in &def.files {
107        let content = std::fs::read(canonical_root.join(f)).ok();
108        files.push(HookFile {
109            path: f.clone(),
110            digest: content.map_or_else(
111                || "missing".to_owned(),
112                |c| format!("sha256:{}", sha256_hex(&c)),
113            ),
114        });
115    }
116    for arg in def.argv().unwrap_or_default() {
117        let candidate = Path::new(arg);
118        let full = if candidate.is_absolute() {
119            candidate.to_path_buf()
120        } else {
121            canonical_root.join(candidate)
122        };
123        let Ok(resolved) = std::fs::canonicalize(&full) else {
124            continue;
125        };
126        if !resolved.is_file() {
127            continue;
128        }
129        match resolved.strip_prefix(&canonical_root) {
130            Ok(rel) => {
131                let content = std::fs::read(&resolved).unwrap_or_default();
132                let path = rel.to_string_lossy().replace('\\', "/");
133                if !files.iter().any(|f: &HookFile| f.path == path) {
134                    files.push(HookFile {
135                        path,
136                        digest: format!("sha256:{}", sha256_hex(&content)),
137                    });
138                }
139            }
140            // Absolute, or a link out of the repository: named, not digested.
141            Err(_) => outside.push(arg.clone()),
142        }
143    }
144    let mut bytes = def.definition.to_string().into_bytes();
145    for f in &files {
146        bytes.push(0);
147        bytes.extend_from_slice(f.path.as_bytes());
148        bytes.push(0);
149        bytes.extend_from_slice(f.digest.as_bytes());
150    }
151    Digested {
152        def: def.clone(),
153        digest: format!("sha256:{}", sha256_hex(&bytes)),
154        files,
155        outside,
156    }
157}
158
159/// The files every hook of the project at `project` runs, relative to the
160/// worktree `wt_root`: judge files, which the model may not write.
161#[must_use]
162pub fn judge_files(project: &Path, wt_root: &Path) -> Vec<String> {
163    let prefix = std::fs::canonicalize(project)
164        .ok()
165        .zip(std::fs::canonicalize(wt_root).ok())
166        .and_then(|(p, w)| p.strip_prefix(&w).ok().map(Path::to_path_buf))
167        .unwrap_or_default();
168    load(project)
169        .unwrap_or_default()
170        .iter()
171        .flat_map(|d| digest(d, project).files)
172        .map(|f| prefix.join(&f.path).to_string_lossy().replace('\\', "/"))
173        .collect()
174}
175
176/// Where hooks keep their state for the project at `root`, under
177/// speccoder's state directory `state`.
178#[must_use]
179pub fn dir(state: &Path, root: &Path) -> PathBuf {
180    crate::worktree::project_state_dir(state, root).join("hooks")
181}
182
183/// The project's id, as an event's `source` names it.
184#[must_use]
185pub fn project_id(state: &Path, root: &Path) -> String {
186    crate::worktree::project_state_dir(state, root)
187        .file_name()
188        .map(|n| n.to_string_lossy().into_owned())
189        .unwrap_or_default()
190}
191
192/// How many runs each hook keeps.
193pub const RECENT: usize = 20;
194
195const RUNS: &str = "runs.json";
196
197/// Every hook's recent runs, oldest first.
198#[must_use]
199pub fn runs(dir: &Path) -> std::collections::BTreeMap<String, Vec<HookRun>> {
200    std::fs::read_to_string(dir.join(RUNS))
201        .ok()
202        .and_then(|t| serde_json::from_str(&t).ok())
203        .unwrap_or_default()
204}
205
206/// Adds `run` to `hook`'s recent runs, keeping the last [`RECENT`].
207pub fn record_run(dir: &Path, hook: &str, run: HookRun) {
208    let mut all = runs(dir);
209    let list = all.entry(hook.to_owned()).or_default();
210    list.push(run);
211    let excess = list.len().saturating_sub(RECENT);
212    list.drain(..excess);
213    write_json(dir, RUNS, &all);
214}
215
216pub(crate) fn write_json(dir: &Path, name: &str, value: &impl Serialize) {
217    let _ = std::fs::create_dir_all(dir);
218    if let Ok(text) = serde_json::to_string_pretty(value) {
219        let tmp = dir.join(format!(".{name}.tmp"));
220        if std::fs::write(&tmp, text).is_ok() {
221            let _ = std::fs::rename(&tmp, dir.join(name));
222        }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    const TOML: &str = r#"
231[hooks.licence]
232at = "tick"
233argv = ["sh", "scripts/licence.sh"]
234
235[hooks.chat]
236on = ["*"]
237url = "https://hooks.example.com/x"
238"#;
239
240    #[test]
241    fn hooks_are_read_by_name_and_a_bad_one_is_an_error() {
242        let hooks = parse(TOML).unwrap();
243        assert_eq!(
244            hooks.iter().map(|h| h.name.as_str()).collect::<Vec<_>>(),
245            ["chat", "licence"]
246        );
247        assert!(parse("[hooks]\nx = 1\n").is_err());
248        assert!(parse("").unwrap().is_empty());
249    }
250
251    #[test]
252    fn a_byte_of_the_definition_or_its_script_changes_the_digest() {
253        let dir = tempfile::tempdir().unwrap();
254        std::fs::create_dir_all(dir.path().join("scripts")).unwrap();
255        std::fs::write(dir.path().join("scripts/licence.sh"), "exit 0\n").unwrap();
256        let hooks = parse(TOML).unwrap();
257        let gate = &hooks[1];
258        let first = digest(gate, dir.path());
259        assert_eq!(first.files.len(), 1);
260        assert_eq!(first.files[0].path, "scripts/licence.sh");
261        assert_eq!(digest(gate, dir.path()).digest, first.digest);
262        std::fs::write(dir.path().join("scripts/licence.sh"), "exit 1\n").unwrap();
263        assert_ne!(digest(gate, dir.path()).digest, first.digest);
264        let reordered = parse(
265            "[hooks.licence]\nargv = [\"sh\", \"scripts/licence.sh\"]\n# a comment\nat = \"tick\"\n",
266        )
267        .unwrap();
268        assert_eq!(
269            digest(&reordered[0], dir.path()).digest,
270            digest(gate, dir.path()).digest
271        );
272        let longer =
273            parse("[hooks.licence]\nat = \"tick\"\nargv = [\"sh\", \"scripts/licence.sh\"]\ntimeout = 61\n")
274                .unwrap();
275        assert_ne!(
276            digest(&longer[0], dir.path()).digest,
277            digest(gate, dir.path()).digest
278        );
279    }
280
281    #[test]
282    fn only_gates_join_the_judges_configuration() {
283        let table: toml::Table = toml::from_str(TOML).unwrap();
284        let v = gate_tables(&table);
285        assert!(v.get("licence").is_some() && v.get("chat").is_none());
286        let moved: toml::Table =
287            toml::from_str(&TOML.replace("hooks.example.com", "other.example.com")).unwrap();
288        assert_eq!(gate_tables(&moved), v);
289    }
290
291    #[test]
292    fn a_hook_keeps_its_last_twenty_runs() {
293        let dir = tempfile::tempdir().unwrap();
294        for i in 0..25 {
295            record_run(
296                dir.path(),
297                "licence",
298                HookRun {
299                    at: format!("{i}"),
300                    what: "test".into(),
301                    outcome: "passed".into(),
302                    duration_ms: 1,
303                    error: None,
304                },
305            );
306        }
307        let r = &runs(dir.path())["licence"];
308        assert_eq!(r.len(), RECENT);
309        assert_eq!(r[0].at, "5");
310    }
311}