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