1#[derive(Debug, Clone, PartialEq)]
5pub struct Tick {
6 pub id: String, pub parent_id: String, pub observe: String, pub decision: String, pub grounds: Vec<Ground>, pub status: String, pub held_since: String, pub blame: String, pub authority: Option<String>, }
16
17#[derive(Debug, Clone, PartialEq)]
18pub struct Ground {
19 pub claim: String,
20 pub supports: String, pub check: Option<Check>,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25pub enum Check {
26 Person {
27 reference: String,
28 }, Test {
30 reference: String, verified_at_sha: String, counter_test: String,
33 liveness: Liveness,
34 },
35}
36
37#[derive(Debug, Clone, PartialEq)]
38pub struct Liveness {
39 pub platforms: Vec<String>,
40 pub triggered_by: Vec<String>,
41 pub surfaces: Vec<String>,
42}
43
44use crate::canonical::hashed_value;
45use serde_json::{Map, Value};
46
47pub fn full_value(t: &Tick) -> Value {
49 let mut v = hashed_value(t);
50 if let Value::Object(map) = &mut v {
51 map.insert("id".into(), Value::String(t.id.clone()));
52 map.insert("status".into(), Value::String(t.status.clone()));
53 map.insert("held_since".into(), Value::String(t.held_since.clone()));
54 map.insert("blame".into(), Value::String(t.blame.clone()));
55 if let Some(a) = &t.authority {
56 map.insert("authority".into(), Value::String(a.clone()));
57 }
58 }
59 v
60}
61
62pub(crate) fn only_keys(
63 obj: &Map<String, Value>,
64 allowed: &[&str],
65 what: &str,
66) -> Result<(), String> {
67 for k in obj.keys() {
68 if !allowed.contains(&k.as_str()) {
69 return Err(format!("{what}: field outside closed schema: {k}"));
70 }
71 }
72 Ok(())
73}
74
75pub(crate) fn req_str(obj: &Map<String, Value>, k: &str) -> Result<String, String> {
76 obj.get(k)
77 .and_then(|x| x.as_str())
78 .map(|s| s.to_string())
79 .ok_or(format!("missing or non-string field: {k}"))
80}
81
82pub(crate) fn is_40_lower_hex(s: &str) -> bool {
83 s.len() == 40
84 && s.bytes()
85 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
86}
87
88fn nonempty_str_set(obj: &Map<String, Value>, k: &str) -> Result<Vec<String>, String> {
89 let a = obj
90 .get(k)
91 .and_then(|x| x.as_array())
92 .ok_or(format!("liveness.{k} missing/not array"))?;
93 let mut out = Vec::new();
94 for e in a {
95 let s = e
96 .as_str()
97 .ok_or(format!("liveness.{k} element not a string"))?;
98 if s.is_empty() {
99 return Err(format!("liveness.{k} has an empty element"));
100 }
101 out.push(s.to_string());
102 }
103 if out.is_empty() {
104 return Err(format!("liveness.{k} must be non-empty"));
105 }
106 Ok(out)
107}
108
109fn check_from_value(v: &Value) -> Result<Check, String> {
110 let obj = v.as_object().ok_or("check is not an object")?;
111 match obj.get("by").and_then(|x| x.as_str()) {
112 Some("person") => {
113 only_keys(obj, &["by", "ref"], "person check")?;
114 let reference = req_str(obj, "ref")?;
115 if reference.is_empty() {
116 return Err("person check ref is empty".into());
117 }
118 Ok(Check::Person { reference })
119 }
120 Some("test") => {
121 only_keys(
122 obj,
123 &["by", "ref", "verified_at_sha", "counter_test", "liveness"],
124 "test check",
125 )?;
126 let reference = req_str(obj, "ref")?;
127 if reference.is_empty() {
128 return Err("test check ref is empty".into());
129 }
130 let verified_at_sha = req_str(obj, "verified_at_sha")?;
131 if !is_40_lower_hex(&verified_at_sha) {
132 return Err(format!(
133 "verified_at_sha must be 40 lowercase hex: {verified_at_sha}"
134 ));
135 }
136 let counter_test = req_str(obj, "counter_test")?;
137 let lv = obj
138 .get("liveness")
139 .and_then(|x| x.as_object())
140 .ok_or("liveness missing/not object")?;
141 only_keys(lv, &["platforms", "triggered_by", "surfaces"], "liveness")?;
142 let liveness = Liveness {
143 platforms: nonempty_str_set(lv, "platforms")?,
144 triggered_by: nonempty_str_set(lv, "triggered_by")?,
145 surfaces: nonempty_str_set(lv, "surfaces")?,
146 };
147 Ok(Check::Test {
148 reference,
149 verified_at_sha,
150 counter_test,
151 liveness,
152 })
153 }
154 other => Err(format!(
155 "check.by must be \"test\" or \"person\", got {other:?}"
156 )),
157 }
158}
159
160fn ground_from_value(v: &Value) -> Result<Ground, String> {
161 let obj = v.as_object().ok_or("ground is not an object")?;
162 only_keys(obj, &["claim", "supports", "check"], "ground")?;
163 let claim = req_str(obj, "claim")?;
164 if claim.is_empty() {
165 return Err("ground claim is empty".into());
166 }
167 let supports = req_str(obj, "supports")?;
168 let ok_supports = supports == "chosen"
169 || (supports.starts_with("rejected:") && supports.len() > "rejected:".len());
170 if !ok_supports {
171 return Err(format!("invalid supports: {supports}"));
172 }
173 let check = match obj.get("check") {
174 None => None,
175 Some(cv) => Some(check_from_value(cv)?),
176 };
177 Ok(Ground {
178 claim,
179 supports,
180 check,
181 })
182}
183
184pub fn from_value(v: &Value) -> Result<Tick, String> {
186 let obj = v.as_object().ok_or("tick is not an object")?;
187 only_keys(
188 obj,
189 &[
190 "id",
191 "parent_id",
192 "observe",
193 "decision",
194 "grounds",
195 "status",
196 "held_since",
197 "blame",
198 "authority",
199 ],
200 "tick",
201 )?;
202 let grounds_v = obj
203 .get("grounds")
204 .and_then(|x| x.as_array())
205 .ok_or("grounds missing/not array")?;
206 let mut grounds = Vec::new();
207 for gv in grounds_v {
208 grounds.push(ground_from_value(gv)?);
209 }
210 Ok(Tick {
211 id: req_str(obj, "id")?,
212 parent_id: req_str(obj, "parent_id")?,
213 observe: req_str(obj, "observe")?,
214 decision: req_str(obj, "decision")?,
215 grounds,
216 status: req_str(obj, "status")?,
217 held_since: req_str(obj, "held_since")?,
218 blame: req_str(obj, "blame")?,
219 authority: obj
220 .get("authority")
221 .and_then(|x| x.as_str())
222 .map(String::from),
223 })
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use serde_json::json;
230
231 fn genesis_full() -> serde_json::Value {
232 json!({
233 "id": "e2b337f53a1f", "parent_id": "",
234 "observe": "o", "decision": "d",
235 "grounds": [{ "claim": "c", "supports": "chosen",
236 "check": { "by": "person", "ref": "Q3 review" } }],
237 "status": "live", "held_since": "", "blame": "Wang Yu"
238 })
239 }
240
241 #[test]
242 fn from_value_should_round_trip_the_tick_when_it_is_well_formed() {
243 let v = genesis_full();
245
246 let t = from_value(&v).expect("valid");
248
249 assert_eq!(t.decision, "d");
251 assert_eq!(t.grounds.len(), 1);
252 assert!(matches!(t.grounds[0].check, Some(Check::Person { .. })));
253 }
254
255 #[test]
256 fn from_value_should_round_trip_an_authority_tag_when_present() {
257 let mut v = genesis_full();
259 v.as_object_mut()
260 .unwrap()
261 .insert("authority".into(), json!("user-ruled"));
262
263 let t = from_value(&v).expect("valid");
265
266 assert_eq!(t.authority.as_deref(), Some("user-ruled"));
268 }
269
270 #[test]
271 fn from_value_should_default_authority_to_none_when_absent() {
272 let v = genesis_full();
274
275 let t = from_value(&v).expect("valid");
277
278 assert_eq!(t.authority, None);
280 }
281
282 #[test]
283 fn from_value_should_reject_the_tick_when_it_has_an_unknown_top_level_field() {
284 let mut v = genesis_full();
286 v.as_object_mut()
287 .unwrap()
288 .insert("health".into(), json!("0.8"));
289
290 let result = from_value(&v);
292
293 assert!(result.is_err());
295 }
296
297 #[test]
298 fn from_value_should_reject_the_check_when_it_carries_both_test_and_person_shape() {
299 let mut v = genesis_full();
301 v["grounds"][0]["check"] = json!({ "by": "person", "ref": "x", "liveness": {} });
302
303 let result = from_value(&v);
305
306 assert!(result.is_err());
308 }
309
310 #[test]
311 fn from_value_should_reject_the_test_check_when_its_sha_is_not_40_hex() {
312 let mut v = genesis_full();
314 v["grounds"][0]["check"] = json!({
315 "by": "test", "ref": "r", "verified_at_sha": "ABC", "counter_test": "ct",
316 "liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
317 });
318
319 let result = from_value(&v);
321
322 assert!(result.is_err());
324 }
325
326 #[test]
327 fn from_value_should_reject_the_test_check_when_its_ref_is_empty() {
328 let mut v = genesis_full();
330 v["grounds"][0]["check"] = json!({
331 "by": "test", "ref": "", "verified_at_sha": "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901", "counter_test": "ct",
332 "liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
333 });
334
335 let result = from_value(&v);
337
338 assert!(result.is_err());
340 }
341}