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    record_as(
68        dir,
69        &hook.def.name,
70        &hook.digest,
71        hook.def.definition.clone(),
72        by,
73        via,
74    );
75}
76
77/// Records that `by` trusted what `key` names (a hook, or
78/// `extension:<name>`) at `digest`, having seen `definition`.
79pub fn record_as(
80    dir: &Path,
81    key: &str,
82    digest: &str,
83    definition: serde_json::Value,
84    by: &str,
85    via: &str,
86) {
87    let mut all = read(dir);
88    let now = Trust {
89        digest: digest.to_owned(),
90        by: by.to_owned(),
91        via: via.to_owned(),
92        at: crate::attest::now_rfc3339(),
93        definition,
94    };
95    let previous = all.remove(key).and_then(|e| {
96        if e.current.digest == now.digest {
97            e.previous
98        } else {
99            Some(e.current)
100        }
101    });
102    all.insert(
103        key.to_owned(),
104        Entry {
105            current: now,
106            previous,
107        },
108    );
109    super::write_json(dir, FILE, &all);
110}
111
112/// Forgets what `key` names.
113pub fn forget(dir: &Path, key: &str) {
114    let mut all = read(dir);
115    if all.remove(key).is_some() {
116        super::write_json(dir, FILE, &all);
117    }
118}
119
120/// `--trust-hook name=sha256:…`, split.
121///
122/// # Errors
123///
124/// The value is not `name=digest`.
125pub fn parse_flag(value: &str) -> Result<(String, String), String> {
126    match value.split_once('=') {
127        Some((n, d)) if !n.is_empty() && d.starts_with("sha256:") => {
128            Ok((n.to_owned(), d.to_owned()))
129        }
130        _ => Err(format!(
131            "--trust-hook {value}: give <name>=sha256:<digest>, as `speccoder hooks` prints it"
132        )),
133    }
134}
135
136/// Trusts the hooks `flags` name, each only for its current digest
137/// (`via = ci`): the non-interactive path.
138///
139/// # Errors
140///
141/// A flag names no hook, or a digest that is not the hook's current one
142/// (the message names the current digest).
143pub fn apply_flags(
144    dir: &Path,
145    hooks: &[Digested],
146    flags: &[(String, String)],
147    by: &str,
148) -> Result<(), String> {
149    for (name, digest) in flags {
150        let Some(hook) = hooks.iter().find(|h| &h.def.name == name) else {
151            return Err(format!("--trust-hook {name}: there is no hook `{name}`"));
152        };
153        if let Some((ext, _)) = &hook.def.extension {
154            return Err(format!(
155                "--trust-hook {name}: it comes from the extension {ext}, trusted as a whole: --trust-extension {ext}=<digest>"
156            ));
157        }
158        if &hook.digest != digest {
159            return Err(format!(
160                "--trust-hook {name}: the hook is now {}, not {digest} — review it (speccoder hooks) before trusting",
161                hook.digest
162            ));
163        }
164        if state(dir, name, digest) != TrustState::Trusted {
165            record(dir, hook, by, "ci");
166        }
167    }
168    Ok(())
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn hook(dir: &Path, timeout: u64) -> Digested {
176        let defs = super::super::parse(&format!(
177            "[hooks.licence]\nat = \"tick\"\nargv = [\"true\"]\ntimeout = {timeout}\n"
178        ))
179        .unwrap();
180        super::super::digest(&defs[0], dir)
181    }
182
183    #[test]
184    fn trust_holds_until_the_bytes_change() {
185        let state_dir = tempfile::tempdir().unwrap();
186        let repo = tempfile::tempdir().unwrap();
187        let h = hook(repo.path(), 5);
188        let d = state_dir.path();
189        assert_eq!(state(d, "licence", &h.digest), TrustState::Untrusted);
190        record(d, &h, "Ada", "terminal");
191        assert_eq!(state(d, "licence", &h.digest), TrustState::Trusted);
192        let changed = hook(repo.path(), 6);
193        assert_eq!(state(d, "licence", &changed.digest), TrustState::Changed);
194        assert_eq!(last_trusted(d, "licence").unwrap().digest, h.digest);
195        record(d, &changed, "Ada", "terminal");
196        let e = &read(d)["licence"];
197        assert_eq!(e.current.digest, changed.digest);
198        assert_eq!(e.previous.as_ref().unwrap().digest, h.digest);
199    }
200
201    #[test]
202    fn a_flag_trusts_only_the_current_digest() {
203        let state_dir = tempfile::tempdir().unwrap();
204        let repo = tempfile::tempdir().unwrap();
205        let h = hook(repo.path(), 5);
206        let d = state_dir.path();
207        let stale = vec![("licence".to_owned(), "sha256:00".to_owned())];
208        let e = apply_flags(d, std::slice::from_ref(&h), &stale, "ci").unwrap_err();
209        assert!(e.contains(&h.digest), "{e}");
210        assert_eq!(state(d, "licence", &h.digest), TrustState::Untrusted);
211        let good = vec![("licence".to_owned(), h.digest.clone())];
212        apply_flags(d, std::slice::from_ref(&h), &good, "ci").unwrap();
213        assert_eq!(read(d)["licence"].current.via, "ci");
214        assert!(apply_flags(d, &[h], &[("nope".into(), "sha256:1".into())], "ci").is_err());
215        assert!(parse_flag("licence").is_err());
216        assert_eq!(
217            parse_flag("licence=sha256:ab").unwrap(),
218            ("licence".to_owned(), "sha256:ab".to_owned())
219        );
220    }
221}