1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct Trust {
18 pub digest: String,
20 pub by: String,
22 pub via: String,
24 pub at: String,
26 pub definition: serde_json::Value,
28}
29
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct Entry {
33 pub current: Trust,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub previous: Option<Trust>,
38}
39
40#[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#[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#[must_use]
61pub fn last_trusted(dir: &Path, name: &str) -> Option<Trust> {
62 read(dir).remove(name).map(|e| e.current)
63}
64
65pub 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
92pub 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
108pub 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}