#[derive(Debug, Clone, PartialEq)]
pub struct Tick {
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>, }
#[derive(Debug, Clone, PartialEq)]
pub struct Ground {
pub claim: String,
pub supports: String, pub check: Option<Check>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Check {
Person {
reference: String,
}, Test {
reference: String, verified_at_sha: String, counter_test: String,
liveness: Liveness,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct Liveness {
pub platforms: Vec<String>,
pub triggered_by: Vec<String>,
pub surfaces: Vec<String>,
}
use crate::canonical::hashed_value;
use serde_json::{Map, Value};
pub fn full_value(t: &Tick) -> Value {
let mut v = hashed_value(t);
if let Value::Object(map) = &mut v {
map.insert("id".into(), Value::String(t.id.clone()));
map.insert("status".into(), Value::String(t.status.clone()));
map.insert("held_since".into(), Value::String(t.held_since.clone()));
map.insert("blame".into(), Value::String(t.blame.clone()));
if let Some(a) = &t.authority {
map.insert("authority".into(), Value::String(a.clone()));
}
}
v
}
pub(crate) fn only_keys(
obj: &Map<String, Value>,
allowed: &[&str],
what: &str,
) -> Result<(), String> {
for k in obj.keys() {
if !allowed.contains(&k.as_str()) {
return Err(format!("{what}: field outside closed schema: {k}"));
}
}
Ok(())
}
pub(crate) fn req_str(obj: &Map<String, Value>, k: &str) -> Result<String, String> {
obj.get(k)
.and_then(|x| x.as_str())
.map(|s| s.to_string())
.ok_or(format!("missing or non-string field: {k}"))
}
pub(crate) fn is_40_lower_hex(s: &str) -> bool {
s.len() == 40
&& s.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
fn nonempty_str_set(obj: &Map<String, Value>, k: &str) -> Result<Vec<String>, String> {
let a = obj
.get(k)
.and_then(|x| x.as_array())
.ok_or(format!("liveness.{k} missing/not array"))?;
let mut out = Vec::new();
for e in a {
let s = e
.as_str()
.ok_or(format!("liveness.{k} element not a string"))?;
if s.is_empty() {
return Err(format!("liveness.{k} has an empty element"));
}
out.push(s.to_string());
}
if out.is_empty() {
return Err(format!("liveness.{k} must be non-empty"));
}
Ok(out)
}
fn check_from_value(v: &Value) -> Result<Check, String> {
let obj = v.as_object().ok_or("check is not an object")?;
match obj.get("by").and_then(|x| x.as_str()) {
Some("person") => {
only_keys(obj, &["by", "ref"], "person check")?;
let reference = req_str(obj, "ref")?;
if reference.is_empty() {
return Err("person check ref is empty".into());
}
Ok(Check::Person { reference })
}
Some("test") => {
only_keys(
obj,
&["by", "ref", "verified_at_sha", "counter_test", "liveness"],
"test check",
)?;
let reference = req_str(obj, "ref")?;
if reference.is_empty() {
return Err("test check ref is empty".into());
}
let verified_at_sha = req_str(obj, "verified_at_sha")?;
if !is_40_lower_hex(&verified_at_sha) {
return Err(format!(
"verified_at_sha must be 40 lowercase hex: {verified_at_sha}"
));
}
let counter_test = req_str(obj, "counter_test")?;
let lv = obj
.get("liveness")
.and_then(|x| x.as_object())
.ok_or("liveness missing/not object")?;
only_keys(lv, &["platforms", "triggered_by", "surfaces"], "liveness")?;
let liveness = Liveness {
platforms: nonempty_str_set(lv, "platforms")?,
triggered_by: nonempty_str_set(lv, "triggered_by")?,
surfaces: nonempty_str_set(lv, "surfaces")?,
};
Ok(Check::Test {
reference,
verified_at_sha,
counter_test,
liveness,
})
}
other => Err(format!(
"check.by must be \"test\" or \"person\", got {other:?}"
)),
}
}
fn ground_from_value(v: &Value) -> Result<Ground, String> {
let obj = v.as_object().ok_or("ground is not an object")?;
only_keys(obj, &["claim", "supports", "check"], "ground")?;
let claim = req_str(obj, "claim")?;
if claim.is_empty() {
return Err("ground claim is empty".into());
}
let supports = req_str(obj, "supports")?;
let ok_supports = supports == "chosen"
|| (supports.starts_with("rejected:") && supports.len() > "rejected:".len());
if !ok_supports {
return Err(format!("invalid supports: {supports}"));
}
let check = match obj.get("check") {
None => None,
Some(cv) => Some(check_from_value(cv)?),
};
Ok(Ground {
claim,
supports,
check,
})
}
pub fn from_value(v: &Value) -> Result<Tick, String> {
let obj = v.as_object().ok_or("tick is not an object")?;
only_keys(
obj,
&[
"id",
"parent_id",
"observe",
"decision",
"grounds",
"status",
"held_since",
"blame",
"authority",
],
"tick",
)?;
let grounds_v = obj
.get("grounds")
.and_then(|x| x.as_array())
.ok_or("grounds missing/not array")?;
let mut grounds = Vec::new();
for gv in grounds_v {
grounds.push(ground_from_value(gv)?);
}
Ok(Tick {
id: req_str(obj, "id")?,
parent_id: req_str(obj, "parent_id")?,
observe: req_str(obj, "observe")?,
decision: req_str(obj, "decision")?,
grounds,
status: req_str(obj, "status")?,
held_since: req_str(obj, "held_since")?,
blame: req_str(obj, "blame")?,
authority: obj
.get("authority")
.and_then(|x| x.as_str())
.map(String::from),
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn genesis_full() -> serde_json::Value {
json!({
"id": "e2b337f53a1f", "parent_id": "",
"observe": "o", "decision": "d",
"grounds": [{ "claim": "c", "supports": "chosen",
"check": { "by": "person", "ref": "Q3 review" } }],
"status": "live", "held_since": "", "blame": "Wang Yu"
})
}
#[test]
fn from_value_should_round_trip_the_tick_when_it_is_well_formed() {
let v = genesis_full();
let t = from_value(&v).expect("valid");
assert_eq!(t.decision, "d");
assert_eq!(t.grounds.len(), 1);
assert!(matches!(t.grounds[0].check, Some(Check::Person { .. })));
}
#[test]
fn from_value_should_round_trip_an_authority_tag_when_present() {
let mut v = genesis_full();
v.as_object_mut()
.unwrap()
.insert("authority".into(), json!("user-ruled"));
let t = from_value(&v).expect("valid");
assert_eq!(t.authority.as_deref(), Some("user-ruled"));
}
#[test]
fn from_value_should_default_authority_to_none_when_absent() {
let v = genesis_full();
let t = from_value(&v).expect("valid");
assert_eq!(t.authority, None);
}
#[test]
fn from_value_should_reject_the_tick_when_it_has_an_unknown_top_level_field() {
let mut v = genesis_full();
v.as_object_mut()
.unwrap()
.insert("health".into(), json!("0.8"));
let result = from_value(&v);
assert!(result.is_err());
}
#[test]
fn from_value_should_reject_the_check_when_it_carries_both_test_and_person_shape() {
let mut v = genesis_full();
v["grounds"][0]["check"] = json!({ "by": "person", "ref": "x", "liveness": {} });
let result = from_value(&v);
assert!(result.is_err());
}
#[test]
fn from_value_should_reject_the_test_check_when_its_sha_is_not_40_hex() {
let mut v = genesis_full();
v["grounds"][0]["check"] = json!({
"by": "test", "ref": "r", "verified_at_sha": "ABC", "counter_test": "ct",
"liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
});
let result = from_value(&v);
assert!(result.is_err());
}
#[test]
fn from_value_should_reject_the_test_check_when_its_ref_is_empty() {
let mut v = genesis_full();
v["grounds"][0]["check"] = json!({
"by": "test", "ref": "", "verified_at_sha": "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901", "counter_test": "ct",
"liveness": { "platforms": ["p"], "triggered_by": ["t"], "surfaces": ["s"] }
});
let result = from_value(&v);
assert!(result.is_err());
}
}