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>, pub jurisdiction: Option<String>, pub round_id: Option<String>, }
18
19#[derive(Debug, Clone, PartialEq)]
20pub struct Ground {
21 pub claim: String,
22 pub supports: String, pub check: Option<Check>,
24}
25
26#[derive(Debug, Clone, PartialEq)]
27pub enum Check {
28 Person {
29 reference: String,
30 }, Test {
32 reference: String, verified_at_sha: String, counter_test: Option<String>, liveness: Liveness,
36 },
37}
38
39#[derive(Debug, Clone, PartialEq)]
40pub struct Liveness {
41 pub platforms: Vec<String>,
42 pub triggered_by: Vec<String>,
43 pub surfaces: Vec<String>,
44}
45
46use crate::canonical::hashed_value;
47use serde_json::{Map, Value};
48
49pub fn full_value(t: &Tick) -> Value {
51 let mut v = hashed_value(t);
52 if let Value::Object(map) = &mut v {
53 map.insert("id".into(), Value::String(t.id.clone()));
54 map.insert("status".into(), Value::String(t.status.clone()));
55 map.insert("held_since".into(), Value::String(t.held_since.clone()));
56 map.insert("blame".into(), Value::String(t.blame.clone()));
57 if let Some(a) = &t.authority {
58 map.insert("authority".into(), Value::String(a.clone()));
59 }
60 if let Some(j) = &t.jurisdiction {
61 map.insert("jurisdiction".into(), Value::String(j.clone()));
62 }
63 if let Some(r) = &t.round_id {
64 map.insert("round_id".into(), Value::String(r.clone()));
65 }
66 }
67 v
68}
69
70pub(crate) fn validate_jurisdiction(val: &str) -> Result<(), String> {
72 if matches!(val, "A" | "B" | "C" | "D") {
73 Ok(())
74 } else {
75 Err(format!(
76 "jurisdiction must be one of A, B, C, D (got {val:?})"
77 ))
78 }
79}
80
81pub(crate) fn only_keys(
82 obj: &Map<String, Value>,
83 allowed: &[&str],
84 what: &str,
85) -> Result<(), String> {
86 for k in obj.keys() {
87 if !allowed.contains(&k.as_str()) {
88 return Err(format!("{what}: field outside closed schema: {k}"));
89 }
90 }
91 Ok(())
92}
93
94pub(crate) fn req_str(obj: &Map<String, Value>, k: &str) -> Result<String, String> {
95 obj.get(k)
96 .and_then(|x| x.as_str())
97 .map(|s| s.to_string())
98 .ok_or(format!("missing or non-string field: {k}"))
99}
100
101pub(crate) fn is_40_lower_hex(s: &str) -> bool {
102 s.len() == 40
103 && s.bytes()
104 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
105}
106
107fn nonempty_str_set(obj: &Map<String, Value>, k: &str) -> Result<Vec<String>, String> {
108 let a = obj
109 .get(k)
110 .and_then(|x| x.as_array())
111 .ok_or(format!("liveness.{k} missing/not array"))?;
112 let mut out = Vec::new();
113 for e in a {
114 let s = e
115 .as_str()
116 .ok_or(format!("liveness.{k} element not a string"))?;
117 if s.is_empty() {
118 return Err(format!("liveness.{k} has an empty element"));
119 }
120 out.push(s.to_string());
121 }
122 if out.is_empty() {
123 return Err(format!("liveness.{k} must be non-empty"));
124 }
125 Ok(out)
126}
127
128fn check_from_value(v: &Value) -> Result<Check, String> {
129 let obj = v.as_object().ok_or("check is not an object")?;
130 match obj.get("by").and_then(|x| x.as_str()) {
131 Some("person") => {
132 only_keys(obj, &["by", "ref"], "person check")?;
133 let reference = req_str(obj, "ref")?;
134 if reference.is_empty() {
135 return Err("person check ref is empty".into());
136 }
137 Ok(Check::Person { reference })
138 }
139 Some("test") => {
140 only_keys(
141 obj,
142 &["by", "ref", "verified_at_sha", "counter_test", "liveness"],
143 "test check",
144 )?;
145 let reference = req_str(obj, "ref")?;
146 if reference.is_empty() {
147 return Err("test check ref is empty".into());
148 }
149 let verified_at_sha = req_str(obj, "verified_at_sha")?;
150 if !is_40_lower_hex(&verified_at_sha) {
151 return Err(format!(
152 "verified_at_sha must be 40 lowercase hex: {verified_at_sha}"
153 ));
154 }
155 let counter_test = match obj.get("counter_test") {
158 None => None,
159 Some(cv) => {
160 let s = cv.as_str().ok_or("counter_test present but not a string")?;
161 if s.is_empty() {
162 return Err("counter_test present but empty".into());
163 }
164 Some(s.to_string())
165 }
166 };
167 let lv = obj
168 .get("liveness")
169 .and_then(|x| x.as_object())
170 .ok_or("liveness missing/not object")?;
171 only_keys(lv, &["platforms", "triggered_by", "surfaces"], "liveness")?;
172 let liveness = Liveness {
173 platforms: nonempty_str_set(lv, "platforms")?,
174 triggered_by: nonempty_str_set(lv, "triggered_by")?,
175 surfaces: nonempty_str_set(lv, "surfaces")?,
176 };
177 Ok(Check::Test {
178 reference,
179 verified_at_sha,
180 counter_test,
181 liveness,
182 })
183 }
184 other => Err(format!(
185 "check.by must be \"test\" or \"person\", got {other:?}"
186 )),
187 }
188}
189
190fn ground_from_value(v: &Value) -> Result<Ground, String> {
191 let obj = v.as_object().ok_or("ground is not an object")?;
192 only_keys(obj, &["claim", "supports", "check"], "ground")?;
193 let claim = req_str(obj, "claim")?;
194 if claim.is_empty() {
195 return Err("ground claim is empty".into());
196 }
197 let supports = req_str(obj, "supports")?;
198 let ok_supports = supports == "chosen"
199 || (supports.starts_with("rejected:") && supports.len() > "rejected:".len());
200 if !ok_supports {
201 return Err(format!("invalid supports: {supports}"));
202 }
203 let check = match obj.get("check") {
204 None => None,
205 Some(cv) => Some(check_from_value(cv)?),
206 };
207 Ok(Ground {
208 claim,
209 supports,
210 check,
211 })
212}
213
214pub(crate) const HASHED_TOP_LEVEL_KEYS: &[&str] = &[
219 "id",
220 "parent_id",
221 "observe",
222 "decision",
223 "grounds",
224 "status",
225 "held_since",
226 "blame",
227];
228
229pub(crate) const KNOWN_NON_HASHED_KEYS: &[&str] = &["authority", "jurisdiction", "round_id"];
231
232pub(crate) fn unknown_top_level_keys(obj: &Map<String, Value>) -> Vec<String> {
235 obj.keys()
236 .filter(|k| {
237 !HASHED_TOP_LEVEL_KEYS.contains(&k.as_str())
238 && !KNOWN_NON_HASHED_KEYS.contains(&k.as_str())
239 })
240 .cloned()
241 .collect()
242}
243
244pub fn from_value(v: &Value) -> Result<Tick, String> {
253 let obj = v.as_object().ok_or("tick is not an object")?;
254 let grounds_v = obj
255 .get("grounds")
256 .and_then(|x| x.as_array())
257 .ok_or("grounds missing/not array")?;
258 let mut grounds = Vec::new();
259 for gv in grounds_v {
260 grounds.push(ground_from_value(gv)?);
261 }
262 Ok(Tick {
263 id: req_str(obj, "id")?,
264 parent_id: req_str(obj, "parent_id")?,
265 observe: req_str(obj, "observe")?,
266 decision: req_str(obj, "decision")?,
267 grounds,
268 status: req_str(obj, "status")?,
269 held_since: req_str(obj, "held_since")?,
270 blame: req_str(obj, "blame")?,
271 authority: obj
272 .get("authority")
273 .and_then(|x| x.as_str())
274 .map(String::from),
275 jurisdiction: match obj.get("jurisdiction").and_then(|x| x.as_str()) {
276 None => None,
277 Some(j) => {
278 validate_jurisdiction(j)?; Some(j.to_string())
280 }
281 },
282 round_id: match obj.get("round_id") {
283 None => None,
284 Some(rv) => {
285 let s = rv.as_str().ok_or("round_id present but not a string")?;
287 if s.is_empty() {
288 return Err("round_id present but empty".into());
289 }
290 Some(s.to_string())
291 }
292 },
293 })
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use serde_json::json;
300
301 fn genesis_full() -> serde_json::Value {
302 json!({
303 "id": "e2b337f53a1f", "parent_id": "",
304 "observe": "o", "decision": "d",
305 "grounds": [{ "claim": "c", "supports": "chosen",
306 "check": { "by": "person", "ref": "Q3 review" } }],
307 "status": "live", "held_since": "", "blame": "Wang Yu"
308 })
309 }
310
311 #[test]
312 fn from_value_should_round_trip_the_tick_when_it_is_well_formed() {
313 let v = genesis_full();
315
316 let t = from_value(&v).expect("valid");
318
319 assert_eq!(t.decision, "d");
321 assert_eq!(t.grounds.len(), 1);
322 assert!(matches!(t.grounds[0].check, Some(Check::Person { .. })));
323 }
324
325 #[test]
326 fn from_value_should_round_trip_an_authority_tag_when_present() {
327 let mut v = genesis_full();
329 v.as_object_mut()
330 .unwrap()
331 .insert("authority".into(), json!("user-ruled"));
332
333 let t = from_value(&v).expect("valid");
335
336 assert_eq!(t.authority.as_deref(), Some("user-ruled"));
338 }
339
340 #[test]
341 fn from_value_should_default_authority_to_none_when_absent() {
342 let v = genesis_full();
344
345 let t = from_value(&v).expect("valid");
347
348 assert_eq!(t.authority, None);
350 }
351
352 #[test]
353 fn from_value_should_reject_the_tick_when_a_hashed_identity_field_is_missing() {
354 let mut v = genesis_full();
356 v.as_object_mut().unwrap().remove("decision");
357
358 let result = from_value(&v);
360
361 assert!(result.is_err());
363 }
364
365 #[test]
366 fn from_value_should_reject_the_check_when_it_carries_both_test_and_person_shape() {
367 let mut v = genesis_full();
369 v["grounds"][0]["check"] = json!({ "by": "person", "ref": "x", "liveness": {} });
370
371 let result = from_value(&v);
373
374 assert!(result.is_err());
376 }
377
378 #[test]
379 fn from_value_should_reject_the_test_check_when_its_sha_is_not_40_hex() {
380 let mut v = genesis_full();
382 v["grounds"][0]["check"] = json!({
383 "by": "test", "ref": "r", "verified_at_sha": "ABC", "counter_test": "ct",
384 "liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
385 });
386
387 let result = from_value(&v);
389
390 assert!(result.is_err());
392 }
393
394 #[test]
395 fn from_value_should_reject_an_empty_counter_test_when_present() {
396 let mut v = genesis_full();
398 v["grounds"][0]["check"] = json!({
399 "by": "test", "ref": "r", "verified_at_sha": "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901", "counter_test": "",
400 "liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
401 });
402
403 let result = from_value(&v);
405
406 assert!(result.is_err());
408 }
409
410 #[test]
411 fn from_value_should_round_trip_a_harvested_test_check_when_counter_test_is_absent() {
412 let mut v = genesis_full();
414 v["grounds"][0]["check"] = json!({
415 "by": "test", "ref": "r", "verified_at_sha": "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
416 "liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
417 });
418
419 let t = from_value(&v).expect("valid");
421
422 assert!(matches!(
424 &t.grounds[0].check,
425 Some(Check::Test {
426 counter_test: None,
427 ..
428 })
429 ));
430 }
431
432 #[test]
433 fn from_value_should_round_trip_a_jurisdiction_tag_when_present() {
434 let mut v = genesis_full();
436 v.as_object_mut()
437 .unwrap()
438 .insert("jurisdiction".into(), json!("C"));
439
440 let t = from_value(&v).expect("valid");
442
443 assert_eq!(t.jurisdiction.as_deref(), Some("C"));
445 }
446
447 #[test]
448 fn from_value_should_default_jurisdiction_to_none_when_absent() {
449 let v = genesis_full();
451
452 let t = from_value(&v).expect("valid");
454
455 assert_eq!(t.jurisdiction, None);
457 }
458
459 #[test]
460 fn from_value_should_round_trip_round_id_when_present() {
461 let mut v = genesis_full();
463 v.as_object_mut()
464 .unwrap()
465 .insert("round_id".into(), json!("R2289"));
466
467 let t = from_value(&v).expect("valid");
469
470 assert_eq!(t.round_id.as_deref(), Some("R2289"));
472 }
473
474 #[test]
475 fn from_value_should_default_round_id_to_none_when_absent() {
476 let v = genesis_full();
478
479 let t = from_value(&v).expect("valid");
481
482 assert_eq!(t.round_id, None);
484 }
485
486 #[test]
487 fn from_value_should_reject_an_empty_round_id_when_present() {
488 let mut v = genesis_full();
490 v.as_object_mut()
491 .unwrap()
492 .insert("round_id".into(), json!(""));
493
494 let result = from_value(&v);
496
497 assert!(result.is_err());
499 }
500
501 #[test]
502 fn from_value_should_reject_an_out_of_vocab_jurisdiction() {
503 let mut v = genesis_full();
505 v.as_object_mut()
506 .unwrap()
507 .insert("jurisdiction".into(), json!("Z"));
508
509 let result = from_value(&v);
511
512 assert!(result.is_err());
514 }
515
516 #[test]
517 fn from_value_should_reject_the_test_check_when_its_ref_is_empty() {
518 let mut v = genesis_full();
520 v["grounds"][0]["check"] = json!({
521 "by": "test", "ref": "", "verified_at_sha": "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901", "counter_test": "ct",
522 "liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
523 });
524
525 let result = from_value(&v);
527
528 assert!(result.is_err());
530 }
531
532 #[test]
533 fn from_value_should_tolerate_an_unknown_non_hashed_key_when_reading() {
534 let mut v = genesis_full();
536 v.as_object_mut()
537 .unwrap()
538 .insert("future_field".into(), json!("x"));
539
540 let t = from_value(&v).expect("an unknown top-level key is tolerated (parsed-through)");
542
543 assert_eq!(t.decision, "d");
545 assert_eq!(t.observe, "o");
546 assert_eq!(t.grounds.len(), 1);
547 }
548
549 #[test]
550 fn from_value_should_still_reject_an_unknown_key_inside_the_hashed_payload() {
551 let mut v = genesis_full();
553 v["grounds"][0]
554 .as_object_mut()
555 .unwrap()
556 .insert("future_field".into(), json!("x"));
557
558 let result = from_value(&v);
560
561 assert!(result.is_err());
563 }
564}