Skip to main content

speccoder_runtime/hooks/
trust.rs

1//! Who trusted which hook's bytes. A record lives in speccoder's state for
2//! the project, never in the checkout; it holds until the definition or a
3//! file the hook runs changes. The one before it is kept, so trusting a
4//! changed hook shows what changed.
5
6use std::collections::BTreeMap;
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use super::{Digested, TrustState};
12
13const FILE: &str = "trust.json";
14
15/// One person's trust in one hook's bytes.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct Trust {
18    /// What was trusted.
19    pub digest: String,
20    /// Who.
21    pub by: String,
22    /// Through what (`terminal`, `web`, `ci`).
23    pub via: String,
24    /// When (RFC 3339).
25    pub at: String,
26    /// The definition the person saw.
27    pub definition: serde_json::Value,
28}
29
30/// A hook's current record and the one before it.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct Entry {
33    /// The record in force.
34    pub current: Trust,
35    /// The record it replaced.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub previous: Option<Trust>,
38}
39
40/// Every record in `dir`.
41#[must_use]
42pub fn read(dir: &Path) -> BTreeMap<String, Entry> {
43    std::fs::read_to_string(dir.join(FILE))
44        .ok()
45        .and_then(|t| serde_json::from_str(&t).ok())
46        .unwrap_or_default()
47}
48
49/// Whether `name` with `digest` may run.
50#[must_use]
51pub fn state(dir: &Path, name: &str, digest: &str) -> TrustState {
52    match read(dir).get(name) {
53        None => TrustState::Untrusted,
54        Some(e) if e.current.digest == digest => TrustState::Trusted,
55        Some(_) => TrustState::Changed,
56    }
57}
58
59/// The record a changed hook was trusted under, for "what changed".
60#[must_use]
61pub fn last_trusted(dir: &Path, name: &str) -> Option<Trust> {
62    read(dir).remove(name).map(|e| e.current)
63}
64
65/// Records that `by` trusted `hook` as it is now, through `via`.
66pub fn record(dir: &Path, hook: &Digested, by: &str, via: &str) {
67    let mut all = read(dir);
68    let now = Trust {
69        digest: hook.digest.clone(),
70        by: by.to_owned(),
71        via: via.to_owned(),
72        at: crate::attest::now_rfc3339(),
73        definition: hook.def.definition.clone(),
74    };
75    let previous = all.remove(&hook.def.name).and_then(|e| {
76        if e.current.digest == now.digest {
77            e.previous
78        } else {
79            Some(e.current)
80        }
81    });
82    all.insert(
83        hook.def.name.clone(),
84        Entry {
85            current: now,
86            previous,
87        },
88    );
89    super::write_json(dir, FILE, &all);
90}
91
92/// `--trust-hook name=sha256:…`, split.
93///
94/// # Errors
95///
96/// The value is not `name=digest`.
97pub fn parse_flag(value: &str) -> Result<(String, String), String> {
98    match value.split_once('=') {
99        Some((n, d)) if !n.is_empty() && d.starts_with("sha256:") => {
100            Ok((n.to_owned(), d.to_owned()))
101        }
102        _ => Err(format!(
103            "--trust-hook {value}: give <name>=sha256:<digest>, as `speccoder hooks` prints it"
104        )),
105    }
106}
107
108/// Trusts the hooks `flags` name, each only for its current digest
109/// (`via = ci`): the non-interactive path.
110///
111/// # Errors
112///
113/// A flag names no hook, or a digest that is not the hook's current one
114/// (the message names the current digest).
115pub fn apply_flags(
116    dir: &Path,
117    hooks: &[Digested],
118    flags: &[(String, String)],
119    by: &str,
120) -> Result<(), String> {
121    for (name, digest) in flags {
122        let Some(hook) = hooks.iter().find(|h| &h.def.name == name) else {
123            return Err(format!("--trust-hook {name}: there is no hook `{name}`"));
124        };
125        if &hook.digest != digest {
126            return Err(format!(
127                "--trust-hook {name}: the hook is now {}, not {digest} — review it (speccoder hooks) before trusting",
128                hook.digest
129            ));
130        }
131        if state(dir, name, digest) != TrustState::Trusted {
132            record(dir, hook, by, "ci");
133        }
134    }
135    Ok(())
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn hook(dir: &Path, timeout: u64) -> Digested {
143        let defs = super::super::parse(&format!(
144            "[hooks.licence]\nat = \"tick\"\nargv = [\"true\"]\ntimeout = {timeout}\n"
145        ))
146        .unwrap();
147        super::super::digest(&defs[0], dir)
148    }
149
150    #[test]
151    fn trust_holds_until_the_bytes_change() {
152        let state_dir = tempfile::tempdir().unwrap();
153        let repo = tempfile::tempdir().unwrap();
154        let h = hook(repo.path(), 5);
155        let d = state_dir.path();
156        assert_eq!(state(d, "licence", &h.digest), TrustState::Untrusted);
157        record(d, &h, "Ada", "terminal");
158        assert_eq!(state(d, "licence", &h.digest), TrustState::Trusted);
159        let changed = hook(repo.path(), 6);
160        assert_eq!(state(d, "licence", &changed.digest), TrustState::Changed);
161        assert_eq!(last_trusted(d, "licence").unwrap().digest, h.digest);
162        record(d, &changed, "Ada", "terminal");
163        let e = &read(d)["licence"];
164        assert_eq!(e.current.digest, changed.digest);
165        assert_eq!(e.previous.as_ref().unwrap().digest, h.digest);
166    }
167
168    #[test]
169    fn a_flag_trusts_only_the_current_digest() {
170        let state_dir = tempfile::tempdir().unwrap();
171        let repo = tempfile::tempdir().unwrap();
172        let h = hook(repo.path(), 5);
173        let d = state_dir.path();
174        let stale = vec![("licence".to_owned(), "sha256:00".to_owned())];
175        let e = apply_flags(d, std::slice::from_ref(&h), &stale, "ci").unwrap_err();
176        assert!(e.contains(&h.digest), "{e}");
177        assert_eq!(state(d, "licence", &h.digest), TrustState::Untrusted);
178        let good = vec![("licence".to_owned(), h.digest.clone())];
179        apply_flags(d, std::slice::from_ref(&h), &good, "ci").unwrap();
180        assert_eq!(read(d)["licence"].current.via, "ci");
181        assert!(apply_flags(d, &[h], &[("nope".into(), "sha256:1".into())], "ci").is_err());
182        assert!(parse_flag("licence").is_err());
183        assert_eq!(
184            parse_flag("licence=sha256:ab").unwrap(),
185            ("licence".to_owned(), "sha256:ab".to_owned())
186        );
187    }
188}