Skip to main content

speccoder_runtime/hooks/
report.rs

1//! `speccoder hooks`: every hook with its trust and recent runs, and the
2//! three things a person does with one — trust it, test it, deliver what
3//! waits.
4
5use std::path::Path;
6
7use speccoder_core::hooks::{HookEntry, HookTested, HooksReport, Role, Runner, TrustState};
8use speccoder_core::{CommandData, CommandResult, CommandStatus};
9
10use super::{Digested, HookDef};
11
12fn toml_of(name: &str, definition: &serde_json::Value) -> String {
13    let body = toml::to_string(definition).unwrap_or_default();
14    format!("[hooks.{name}]\n{body}")
15}
16
17/// What keeps `def` from running here, with the fix.
18fn problem(def: &HookDef, root: &Path) -> Option<String> {
19    match &def.runner {
20        Runner::Url {
21            secret_env: Some(var),
22            ..
23        } if std::env::var_os(var).is_none() => Some(format!(
24            "${var} is not set: export the signing key, or drop `secret_env`"
25        )),
26        Runner::Argv(argv) => {
27            let program = argv.first()?;
28            let found = if program.contains('/') {
29                root.join(program).is_file() || Path::new(program).is_file()
30            } else {
31                std::env::var_os("PATH")
32                    .is_some_and(|p| std::env::split_paths(&p).any(|d| d.join(program).is_file()))
33            };
34            (!found).then(|| format!("`{program}` is not found: install it, or fix `argv`"))
35        }
36        Runner::Url { .. } => None,
37    }
38}
39
40/// One hook as the list shows it.
41#[must_use]
42pub fn entry(d: &Digested, root: &Path, dir: &Path) -> HookEntry {
43    let def = &d.def;
44    let state = d.trust(dir);
45    let (role, on) = match &def.role {
46        Role::Gate { .. } => ("gate", Vec::new()),
47        Role::Observer { on } => ("observer", on.clone()),
48    };
49    HookEntry {
50        name: def.name.clone(),
51        role: role.to_owned(),
52        listens: def.listens(),
53        at: def.at(),
54        on,
55        runs: match &def.runner {
56            Runner::Argv(a) => a.join(" "),
57            Runner::Url { url, .. } => url.clone(),
58        },
59        state,
60        digest: d.digest.clone(),
61        files: d.files.clone(),
62        outside: d.outside.clone(),
63        definition: toml_of(&def.name, &def.definition),
64        trusted_definition: (state == TrustState::Changed)
65            .then(|| super::trust::last_trusted(dir, &def.name))
66            .flatten()
67            .map(|t| toml_of(&def.name, &t.definition)),
68        recent: super::runs(dir).remove(&def.name).unwrap_or_default(),
69        trust_flag: format!("--trust-hook {}={}", def.name, d.digest),
70        problem: problem(def, root),
71    }
72}
73
74fn result(message: &str, report: HooksReport, status: CommandStatus) -> CommandResult {
75    CommandResult::new("hooks", status, message).with_data(CommandData::Hooks(Box::new(report)))
76}
77
78fn read(
79    root: &Path,
80    state: &Path,
81) -> Result<(Vec<Digested>, std::path::PathBuf), Box<CommandResult>> {
82    match super::load(root) {
83        Ok(defs) => Ok((
84            defs.iter().map(|d| super::digest(d, root)).collect(),
85            super::dir(state, root),
86        )),
87        Err(e) => Err(Box::new(
88            result(
89                &e,
90                HooksReport {
91                    hooks: Vec::new(),
92                    problem: Some(e.clone()),
93                    trusted: Vec::new(),
94                    tested: None,
95                    delivered: None,
96                    pending: None,
97                },
98                CommandStatus::ConfigError,
99            )
100            .with_code("HOOK-MISCONFIGURED"),
101        )),
102    }
103}
104
105fn report_of(hooks: &[Digested], root: &Path, dir: &Path) -> HooksReport {
106    HooksReport {
107        hooks: hooks.iter().map(|d| entry(d, root, dir)).collect(),
108        problem: None,
109        trusted: Vec::new(),
110        tested: None,
111        delivered: None,
112        pending: None,
113    }
114}
115
116fn summary(r: &HooksReport) -> String {
117    let waiting = r
118        .hooks
119        .iter()
120        .filter(|h| h.state != TrustState::Trusted)
121        .count();
122    match (r.hooks.len(), waiting) {
123        (0, _) => "no hooks: add a [hooks.<name>] to speccoder.toml (docs: hooks)".to_owned(),
124        (1, 0) => "1 hook, trusted".to_owned(),
125        (n, 0) => format!("{n} hooks, all trusted"),
126        (n, w) => format!(
127            "{n} hook{} — {w} wait{} for your trust",
128            if n == 1 { "" } else { "s" },
129            if w == 1 { "s" } else { "" }
130        ),
131    }
132}
133
134/// `speccoder hooks`.
135#[must_use]
136pub fn list(root: &Path, state: &Path) -> CommandResult {
137    let (hooks, dir) = match read(root, state) {
138        Ok(h) => h,
139        Err(r) => return *r,
140    };
141    let report = report_of(&hooks, root, &dir);
142    let message = summary(&report);
143    result(&message, report, CommandStatus::Ok)
144}
145
146/// `speccoder hooks trust [<name>]`: each named hook (every one not
147/// trusted, without a name) that `confirm` agrees to, after it saw the
148/// entry. A newly trusted observer hears what happens from now on.
149pub fn trust(
150    root: &Path,
151    state: &Path,
152    name: Option<&str>,
153    via: &str,
154    confirm: &mut dyn FnMut(&HookEntry) -> bool,
155) -> CommandResult {
156    let (hooks, dir) = match read(root, state) {
157        Ok(h) => h,
158        Err(r) => return *r,
159    };
160    if let Some(n) = name
161        && let Some((ext, _)) = hooks
162            .iter()
163            .find(|h| h.def.name == n)
164            .and_then(|h| h.def.extension.clone())
165    {
166        return result(
167            &format!(
168                "{n} comes from the extension {ext}, trusted as a whole: speccoder extensions trust {ext}"
169            ),
170            report_of(&hooks, root, &dir),
171            CommandStatus::UsageError,
172        )
173        .with_fix(vec![format!("speccoder extensions trust {ext}")]);
174    }
175    if let Some(n) = name
176        && !hooks.iter().any(|h| h.def.name == n)
177    {
178        return result(
179            &format!("there is no hook `{n}`"),
180            report_of(&hooks, root, &dir),
181            CommandStatus::NotFound,
182        );
183    }
184    let by = crate::approvals::who_in(root);
185    let mut trusted = Vec::new();
186    for d in hooks
187        .iter()
188        .filter(|h| name.is_none_or(|n| h.def.name == n))
189    {
190        let e = entry(d, root, &dir);
191        // An extension's hook is trusted with its extension, as a whole.
192        if d.def.extension.is_some() || e.state == TrustState::Trusted || !confirm(&e) {
193            continue;
194        }
195        super::trust::record(&dir, d, &by, via);
196        // A new observer hears what happens from now on; one trusted again
197        // after a change goes on where it was, missing nothing.
198        if d.def.at().is_none() && !super::deliver::has_cursor(&dir, &d.def.name) {
199            super::deliver::start_here(&dir, root, &d.def.name);
200        }
201        trusted.push(d.def.name.clone());
202    }
203    let mut report = report_of(&hooks, root, &dir);
204    let message = match trusted.len() {
205        0 => summary(&report),
206        _ => format!("trusted {}: it runs from now on", trusted.join(", ")),
207    };
208    report.trusted = trusted;
209    result(&message, report, CommandStatus::Ok)
210}
211
212/// Trusts `name` from an interface that showed `digest`: refused when the
213/// hook changed since.
214#[must_use]
215pub fn trust_shown(
216    root: &Path,
217    state: &Path,
218    name: &str,
219    digest: &str,
220    via: &str,
221) -> CommandResult {
222    let current = super::load(root)
223        .ok()
224        .and_then(|h| h.into_iter().find(|d| d.name == name))
225        .map(|d| super::digest(&d, root).digest);
226    if current.as_deref() != Some(digest) {
227        return CommandResult::new(
228            "hooks",
229            CommandStatus::UsageError,
230            &format!("{name} changed since it was shown: look at it again before trusting"),
231        )
232        .with_code("CALL-CHANGED");
233    }
234    // Only the bytes that were shown: changed in between, nothing is trusted.
235    trust(root, state, Some(name), via, &mut |e| e.digest == digest)
236}
237
238/// `speccoder hooks test <name>`: a gate alone on the checkout, or a sample
239/// event to an observer. Only a trusted hook runs.
240#[must_use]
241pub fn test(root: &Path, state: &Path, name: &str) -> CommandResult {
242    let (hooks, dir) = match read(root, state) {
243        Ok(h) => h,
244        Err(r) => return *r,
245    };
246    let Some(d) = hooks.iter().find(|h| h.def.name == name) else {
247        return result(
248            &format!("there is no hook `{name}`"),
249            report_of(&hooks, root, &dir),
250            CommandStatus::NotFound,
251        );
252    };
253    if d.trust(&dir) != TrustState::Trusted {
254        let fix = match &d.def.extension {
255            Some((ext, _)) => format!("speccoder extensions trust {ext}"),
256            None => format!("speccoder hooks trust {name}"),
257        };
258        return result(
259            &format!("{name} waits for your trust: it runs only after you trusted it"),
260            report_of(&hooks, root, &dir),
261            CommandStatus::NeedsHuman,
262        )
263        .with_code("HOOK-UNTRUSTED")
264        .with_fix(vec![fix]);
265    }
266    let began = std::time::Instant::now();
267    let tested = if d.def.at().is_some() {
268        let g = super::gate::test(&d.def, root, state);
269        HookTested {
270            name: name.to_owned(),
271            outcome: g.outcome.as_str().to_owned(),
272            detail: g.reason,
273            duration_ms: g.duration_ms,
274        }
275    } else {
276        let sample = super::events::sample(&super::project_id(state, root));
277        let sent = super::deliver::send(&d.def, &sample, root, state);
278        let duration_ms = u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX);
279        super::record_run(
280            &dir,
281            name,
282            super::HookRun {
283                at: crate::attest::now_rfc3339(),
284                what: "test".to_owned(),
285                outcome: if sent.is_ok() { "delivered" } else { "failed" }.to_owned(),
286                duration_ms,
287                error: sent.as_ref().err().cloned(),
288                findings: Vec::new(),
289                findings_error: None,
290            },
291        );
292        HookTested {
293            name: name.to_owned(),
294            outcome: if sent.is_ok() { "delivered" } else { "failed" }.to_owned(),
295            detail: Some(sent.unwrap_or_else(|e| e)),
296            duration_ms,
297        }
298    };
299    let words = match (tested.outcome.as_str(), tested.detail.as_deref()) {
300        ("passed", _) => format!("{name} would pass"),
301        ("blocked", Some(why)) => format!("{name} would block — {why}"),
302        ("delivered", Some(answer)) => format!("{name} took the sample event ({answer})"),
303        ("failed", Some(why)) => format!("{name} did not take the sample event — {why}"),
304        (o, _) => format!("{name}: {o}"),
305    };
306    let mut report = report_of(&hooks, root, &dir);
307    report.tested = Some(tested);
308    result(&words, report, CommandStatus::Ok)
309}
310
311/// `speccoder hooks deliver`: what waits for every trusted observer.
312#[must_use]
313pub fn deliver(root: &Path, state: &Path) -> CommandResult {
314    let (hooks, dir) = match read(root, state) {
315        Ok(h) => h,
316        Err(r) => return *r,
317    };
318    let done = super::deliver::pass(root, state, None);
319    let mut report = report_of(&hooks, root, &dir);
320    report.delivered = Some(done.delivered);
321    report.pending = Some(done.pending);
322    let mut message = if done.busy {
323        "another speccoder process is delivering; it takes what waits".to_owned()
324    } else {
325        format!(
326            "delivered {} event{}; {} pending",
327            done.delivered,
328            if done.delivered == 1 { "" } else { "s" },
329            done.pending
330        )
331    };
332    for (name, why) in &done.failed {
333        message.push_str(&format!("\n{name} failed: {why}"));
334    }
335    if !done.untrusted.is_empty() {
336        message.push_str(&format!(
337            "\nskipped (not trusted): {}",
338            done.untrusted.join(", ")
339        ));
340    }
341    let status = if done.failed.is_empty() {
342        CommandStatus::Ok
343    } else {
344        CommandStatus::Error
345    };
346    result(&message, report, status)
347}
348
349/// `--trust-hook name=digest`…: trusts each for its current digest only
350/// (`via = ci`).
351///
352/// # Errors
353///
354/// A refusal naming the flag that does not hold.
355pub fn apply_flags(root: &Path, state: &Path, flags: &[String]) -> Result<(), Box<CommandResult>> {
356    if flags.is_empty() {
357        return Ok(());
358    }
359    let (hooks, dir) = read(root, state)?;
360    let parsed = flags
361        .iter()
362        .map(|f| super::trust::parse_flag(f))
363        .collect::<Result<Vec<_>, _>>()
364        .map_err(|e| {
365            CommandResult::new("hooks", CommandStatus::UsageError, &e).with_code("USAGE")
366        })?;
367    super::trust::apply_flags(&dir, &hooks, &parsed, &crate::approvals::who_in(root)).map_err(
368        |e| CommandResult::new("hooks", CommandStatus::NeedsHuman, &e).with_code("HOOK-UNTRUSTED"),
369    )?;
370    for (name, _) in &parsed {
371        if hooks
372            .iter()
373            .any(|h| &h.def.name == name && h.def.at().is_none())
374            && !super::deliver::has_cursor(&dir, name)
375        {
376            super::deliver::start_here(&dir, root, name);
377        }
378    }
379    Ok(())
380}
381
382/// The notice a run gives once when observers are not trusted: they hear
383/// nothing until a person trusts them.
384#[must_use]
385pub fn untrusted_notice(root: &Path, state: &Path) -> Option<String> {
386    let (hooks, dir) = read(root, state).ok()?;
387    let waiting: Vec<&str> = hooks
388        .iter()
389        .filter(|h| h.def.at().is_none() && h.def.extension.is_none())
390        .filter(|h| h.trust(&dir) != TrustState::Trusted)
391        .map(|h| h.def.name.as_str())
392        .collect();
393    (!waiting.is_empty()).then(|| {
394        format!(
395            "{} not trusted: {} nothing until you trust {} (speccoder hooks trust)",
396            waiting.join(", "),
397            if waiting.len() == 1 {
398                "it hears"
399            } else {
400                "they hear"
401            },
402            if waiting.len() == 1 { "it" } else { "them" }
403        )
404    })
405}