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 record_as(
68 dir,
69 &hook.def.name,
70 &hook.digest,
71 hook.def.definition.clone(),
72 by,
73 via,
74 );
75}
76
77pub 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
112pub 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
120pub 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
136pub 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}