//! Check a vector pack on disk against the reference implementation. //! //! This reads only the published JSON: every expected value is recomputed //! from the vector's own inputs, and every negative vector must be refused //! with the error class it names. It does not consult the generator, so it //! checks a pack whoever produced it. //! //! It also checks that the pack is complete, against texts that live outside //! the pack: every vector the identity spec cites must be present, and every //! error class the conformance README lists must be exercised (and no other). //! A pack cannot pass by losing vectors. use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::{Path, PathBuf}; use serde_json::Value as S; use smart_byte_core::canonical::{self, Value}; use smart_byte_core::inbox::{Delivery, Inbox}; use smart_byte_core::said::{self, SAID_PLACEHOLDER}; use smart_byte_core::sign::{self, Signer as _, SigningKey}; use smart_byte_core::{Envelope, KeyBook, Said}; use smart_byte_keri_witness::{KeyEvent, LogVerifier, SignedEvent, VerificationReport}; use smart_byte_lockstep::{node_view, supermajority_threshold, tally}; use smart_byte_state::State; use crate::json::{from_json, parse_strict}; use crate::pack::FILES; /// One vector's result. #[derive(Debug, Clone)] pub struct Check { pub file: String, pub vector: String, pub result: Result<(), String>, } /// Every check a pack verification made. #[derive(Debug, Default)] pub struct Report { pub checks: Vec, } impl Report { pub fn failures(&self) -> Vec<&Check> { self.checks.iter().filter(|c| c.result.is_err()).collect() } pub fn passed(&self) -> bool { !self.checks.is_empty() && self.failures().is_empty() } fn record(&mut self, file: &str, vector: &str, result: Result<(), String>) { self.checks.push(Check { file: file.to_owned(), vector: vector.to_owned(), result, }); } } type R = Result<(), String>; fn field<'a>(v: &'a S, key: &str) -> Result<&'a S, String> { v.get(key).ok_or_else(|| format!("missing `{key}`")) } fn text<'a>(v: &'a S, key: &str) -> Result<&'a str, String> { field(v, key)? .as_str() .ok_or_else(|| format!("`{key}` is not a string")) } fn hex_field(v: &S, key: &str) -> Result, String> { hex::decode(text(v, key)?).map_err(|e| format!("`{key}` is not hex: {e}")) } fn hash32(v: &S, what: &str) -> Result<[u8; 32], String> { v.as_str() .and_then(|s| hex::decode(s).ok()) .and_then(|b| b.try_into().ok()) .ok_or_else(|| format!("{what} is not 32 bytes of hex")) } fn same(what: &str, computed: impl PartialEq + std::fmt::Display, published: &str) -> R { if computed == published.to_owned() { Ok(()) } else { Err(format!( "{what}: published {published}, reference computes {computed}" )) } } /// The texts the completeness checks are held to: the identity spec and the /// conformance README in this repository. fn repo_text(relative: &str) -> Result { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative); std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display())) } /// The SAID vector prefixes the identity spec cites (`said-006`, and every /// member of a range written `said-001` to `said-005`). pub fn spec_cited_said_vectors() -> Result, String> { let spec = repo_text("../../../spec/identity_and_key_rotation.md")?; let mut cited = BTreeSet::new(); let words: Vec<&str> = spec .split(|c: char| !(c.is_ascii_alphanumeric() || c == '-')) .filter(|w| !w.is_empty()) .collect(); let number = |w: &str| w.strip_prefix("said-").and_then(|n| n.parse::().ok()); for (i, w) in words.iter().enumerate() { let Some(n) = number(w) else { continue }; cited.insert(format!("said-{n:03}")); if words.get(i + 1) == Some(&"to") && let Some(end) = words.get(i + 2).and_then(|w| number(w)) { for k in n..=end { cited.insert(format!("said-{k:03}")); } } } if cited.is_empty() { return Err("the identity spec cites no SAID vector".into()); } Ok(cited) } /// The error classes the conformance README's table lists. pub fn readme_error_classes() -> Result, String> { let readme = repo_text("../../../conformance/README.md")?; let section = readme .split("### Error classes") .nth(1) .ok_or("the README has no error-class table")?; let classes: BTreeSet = section .lines() .skip_while(|l| !l.starts_with('|')) .take_while(|l| l.starts_with('|')) .filter_map(|l| l.split('`').nth(1).map(str::to_owned)) .collect(); if classes.is_empty() { return Err("the README's error-class table is empty".into()); } Ok(classes) } /// Check every file of the pack in `dir`. pub fn verify_pack(dir: &Path) -> Report { let mut report = Report::default(); let mut files: BTreeMap<&str, Vec> = BTreeMap::new(); for file in FILES { let loaded = std::fs::read(dir.join(file)) .map_err(|e| format!("read: {e}")) .and_then(|b| parse_strict(&b).map_err(|e| format!("parse: {e}"))) .and_then(|j| match j { S::Array(items) => Ok(items), _ => Err("the top-level value must be an array".to_owned()), }); match loaded { Ok(items) => { report.record(file, "(file)", structure(&items)); files.insert(file, items); } Err(e) => report.record(file, "(file)", Err(e)), } } // Key-event logs first: the envelope vectors are checked against the key // states the accepted logs establish. let mut logs: BTreeMap = BTreeMap::new(); for v in files.get("kel_vectors.json").into_iter().flatten() { let name = v.get("name").and_then(S::as_str).unwrap_or("(unnamed)"); let result = kel(v).map(|accepted| { if let Some(r) = accepted { logs.insert(name.to_owned(), r); } }); report.record("kel_vectors.json", name, result); } for (file, items) in &files { for v in items { let name = v.get("name").and_then(S::as_str).unwrap_or("(unnamed)"); let result = match *file { "canonicalisation_vectors.json" => canonicalisation(v), "said_vectors.json" => said_vector(v), "signature_vectors.json" => signature(v), "kel_vectors.json" => continue, "envelope_vectors.json" => envelope(v, &logs), "state_vectors.json" => state_vector(v), "lockstep_vectors.json" => lockstep(v), "gossip_vectors.json" => gossip(v), _ => unreachable!("FILES lists exactly these"), }; report.record(file, name, result); } } report.record( "(pack)", "spec-cited vectors present", cited_present(&files), ); report.record( "(pack)", "README error classes exercised", classes_exercised(&files), ); report } /// Names and descriptions present, names unique, supersessions resolvable, /// and at least one vector. fn structure(items: &[S]) -> R { if items.is_empty() { return Err("the file holds no vectors".into()); } let mut names = BTreeSet::new(); for v in items { let name = text(v, "name")?; text(v, "description").map_err(|e| format!("{name}: {e}"))?; if !names.insert(name) { return Err(format!("duplicate vector name {name}")); } } for v in items { if let Some(target) = v.get("superseded_by").and_then(S::as_str) && !names.contains(target) { return Err(format!( "{} is superseded by {target}, which is not in the file", v["name"] )); } } Ok(()) } fn cited_present(files: &BTreeMap<&str, Vec>) -> R { let names: Vec<&str> = files .get("said_vectors.json") .into_iter() .flatten() .filter_map(|v| v.get("name").and_then(S::as_str)) .collect(); let missing: Vec = spec_cited_said_vectors()? .into_iter() .filter(|prefix| !names.iter().any(|n| n.starts_with(&format!("{prefix}-")))) .collect(); if missing.is_empty() { Ok(()) } else { Err(format!( "the identity spec cites vectors the pack lacks: {missing:?}" )) } } fn classes_exercised(files: &BTreeMap<&str, Vec>) -> R { let mut used = BTreeSet::new(); let mut results = |holder: &S| { for r in holder .get("expected_results") .and_then(S::as_array) .into_iter() .flatten() { if let Some(e) = r.get("error").and_then(S::as_str) { used.insert(e.to_owned()); } } }; let mut errors = BTreeSet::new(); for v in files.values().flatten() { if let Some(e) = v.get("error").and_then(S::as_str) { errors.insert(e.to_owned()); } results(v); for frame in v.get("frames").and_then(S::as_array).into_iter().flatten() { results(frame); } } used.extend(errors); let listed = readme_error_classes()?; let unexercised: Vec<&String> = listed.difference(&used).collect(); let undocumented: Vec<&String> = used.difference(&listed).collect(); if unexercised.is_empty() && undocumented.is_empty() { Ok(()) } else { Err(format!( "README classes no vector exercises: {unexercised:?}; classes the README does not list: {undocumented:?}" )) } } fn refused_as(what: &str, class: Result<(), &'static str>, v: &S) -> R { let want = text(v, "error")?; match class { Ok(()) => Err(format!("{what} accepted input it must refuse with {want}")), Err(got) if got == want => Ok(()), Err(got) => Err(format!("refused with {got}, but the vector says {want}")), } } fn canonicalisation(v: &S) -> R { if let Some(input) = v.get("input") { let value = from_json(input)?; let bytes = canonical::encode(&value).map_err(|e| e.to_string())?; same( "expected_cbor_hex", hex::encode(&bytes), text(v, "expected_cbor_hex")?, )?; let back = canonical::decode(&bytes) .map_err(|e| format!("decoder refuses the encoder's output: {e}"))?; if canonical::encode(&back).map_err(|e| e.to_string())? != bytes { return Err("decode then encode does not reproduce the bytes".into()); } Ok(()) } else { let input = hex_field(v, "input_cbor_hex")?; refused_as( "the decoder", canonical::decode(&input).map(drop).map_err(|e| e.class()), v, ) } } fn said_vector(v: &S) -> R { let field_name = text(v, "said_field")?; if let Some(input) = v.get("input") { let value = from_json(input)?; if value.get(field_name) != Some(&Value::text(SAID_PLACEHOLDER)) { return Err(format!( "`input` must hold the placeholder in `{field_name}` (identity spec §2.1 step 1)" )); } let d = said::derive(&value, field_name).map_err(|e| e.to_string())?; same( "canonical_cbor_hex", hex::encode(&d.hashed), text(v, "canonical_cbor_hex")?, )?; same( "blake3_hex", hex::encode(d.said.as_bytes()), text(v, "blake3_hex")?, )?; same( "expected_said", d.said.to_said_text(), text(v, "expected_said")?, )?; same( "said_cbor_hex", hex::encode(&d.encoded), text(v, "said_cbor_hex")?, )?; let verified = said::verify(&hex_field(v, "said_cbor_hex")?, field_name) .map_err(|e| format!("said_cbor_hex does not verify: {e}"))?; same( "verified SAID", verified.to_said_text(), text(v, "expected_said")?, ) } else { let input = hex_field(v, "input_cbor_hex")?; refused_as( "SAID verification", said::verify(&input, field_name) .map(drop) .map_err(|e| e.class()), v, )?; if let Some(received) = v.get("said_of_received_bytes").and_then(S::as_str) { same( "said_of_received_bytes", said_of_received_bytes(&input, field_name)?, received, )?; } if let Some(reencoded) = v.get("said_after_reencoding").and_then(S::as_str) { let value = lenient_decode(&input)?; let d = said::derive(&value, field_name).map_err(|e| e.to_string())?; same("said_after_reencoding", d.said.to_said_text(), reencoded)?; } Ok(()) } } /// The SAID a verifier that skips the canonical check would compute: the /// received bytes with the stated SAID overwritten in place by the placeholder. fn said_of_received_bytes(input: &[u8], field_name: &str) -> Result { let stated = match lenient_decode(input)?.get(field_name) { Some(Value::Text(t)) if t.len() == SAID_PLACEHOLDER.len() => t.clone(), _ => { return Err(format!( "no {}-character `{field_name}` to substitute", SAID_PLACEHOLDER.len() )); } }; let at = input .windows(stated.len()) .position(|w| w == stated.as_bytes()) .ok_or("the stated SAID is not in the bytes")?; let mut bytes = input.to_vec(); bytes[at..at + stated.len()].copy_from_slice(SAID_PLACEHOLDER.as_bytes()); Ok(Said::hash(&bytes).to_said_text()) } /// A decoder that accepts over-long heads and maps in any order — only so the /// verifier can show what a lenient implementation would compute. Nothing /// that decides acceptance uses it. fn lenient_decode(input: &[u8]) -> Result { fn item(b: &[u8], pos: &mut usize, depth: usize) -> Result { if depth > canonical::MAX_DEPTH { return Err("too deep".into()); } let initial = *b.get(*pos).ok_or("truncated")?; *pos += 1; let (major, ai) = (initial >> 5, initial & 0x1f); if major == 7 { return match ai { 20 => Ok(Value::Bool(false)), 21 => Ok(Value::Bool(true)), 22 => Ok(Value::Null), _ => Err(format!( "lenient decoder: unsupported simple/float {initial:#x}" )), }; } let width = match ai { 0..=23 => 0, 24 => 1, 25 => 2, 26 => 4, 27 => 8, _ => return Err(format!("lenient decoder: unsupported head {initial:#x}")), }; let mut arg = if width == 0 { u64::from(ai) } else { 0 }; for _ in 0..width { arg = (arg << 8) | u64::from(*b.get(*pos).ok_or("truncated")?); *pos += 1; } let mut take = |n: u64| -> Result, String> { let n = usize::try_from(n).map_err(|e| e.to_string())?; let end = pos.checked_add(n).ok_or("truncated")?; let s = b.get(*pos..end).ok_or("truncated")?.to_vec(); *pos = end; Ok(s) }; Ok(match major { 0 => Value::Unsigned(arg), 1 => Value::Negative(arg), 2 => Value::Bytes(take(arg)?), 3 => Value::Text(String::from_utf8(take(arg)?).map_err(|e| e.to_string())?), 4 => { let mut items = Vec::new(); for _ in 0..arg { items.push(item(b, pos, depth + 1)?); } Value::Array(items) } 5 => { let mut entries = Vec::new(); for _ in 0..arg { let k = item(b, pos, depth + 1)?; entries.push((k, item(b, pos, depth + 1)?)); } Value::Map(entries) } _ => return Err(format!("lenient decoder: unsupported major type {major}")), }) } let mut pos = 0; let v = item(input, &mut pos, 0)?; if pos != input.len() { return Err("trailing bytes".into()); } Ok(v) } fn signature(v: &S) -> R { let message = hex_field(v, "message_hex")?; let key = match field(v, "signing_key_seed")? { S::Null => None, seed => { let seed: [u8; 32] = hash32(seed, "signing_key_seed")?; let key = SigningKey::from_bytes(&seed); same( "verifying_key", hex::encode(key.verifying_key().to_bytes()), text(v, "verifying_key")?, )?; Some(key) } }; let sig = hex_field(v, "signature")?; let result = sign::verify(&hex_field(v, "verifying_key")?, &message, &sig); match field(v, "expected_verify")?.as_bool() { Some(true) => { result.map_err(|e| format!("a positive vector fails to verify: {e}"))?; if !field(v, "error")?.is_null() { return Err("a positive vector names an error".into()); } // Ed25519 is deterministic: the published signature is THE // signature, so a positive vector must carry its seed. let key = key.ok_or("a positive vector must carry its signing seed")?; same( "signature", hex::encode(key.sign(&message).to_bytes()), text(v, "signature")?, ) } Some(false) => refused_as("signature verification", result.map_err(|e| e.class()), v), None => Err("expected_verify is not a boolean".into()), } } /// A vector's typed-JSON form of an object, when it gives one, must be the /// object its bytes encode. fn typed_matches_bytes(v: &S, typed: &str, bytes: &[u8]) -> R { if let Some(j) = v.get(typed) { let encoded = canonical::encode(&from_json(j)?).map_err(|e| e.to_string())?; if encoded != bytes { return Err(format!("`{typed}` does not encode to the vector's bytes")); } } Ok(()) } /// Check one key-event-log vector; an accepted log's report is returned for /// the envelope vectors to draw key states from. fn kel(v: &S) -> Result, String> { let events = field(v, "events")? .as_array() .ok_or("events is not an array")?; let mut log = Vec::new(); for (i, e) in events.iter().enumerate() { let bytes = hex_field(e, "cbor_hex")?; typed_matches_bytes(e, "event", &bytes).map_err(|m| format!("event {i}: {m}"))?; let mut signatures = Vec::new(); for s in field(e, "signatures")? .as_array() .ok_or("signatures is not an array")? { let index = field(s, "index")? .as_u64() .and_then(|n| u32::try_from(n).ok()) .ok_or("a signature index is not an integer")?; let signature = hex_field(s, "signature")?; // A signature with a seed is the one that seed's key makes: // Ed25519 is deterministic. if let S::String(_) = field(s, "seed")? { let key = SigningKey::from_bytes(&hash32(&s["seed"], "seed")?); let made = [&[0x01][..], &key.sign(&bytes).to_bytes()].concat(); if made != signature { return Err(format!( "event {i}: a signature is not the one its seed makes" )); } } signatures.push((index, signature)); } log.push(SignedEvent { bytes, signatures }); } let result = LogVerifier::new().verify_encoded(&log, &HashMap::new()); match (v.get("expected"), v.get("error")) { (Some(expected), None) => { let report = result.map_err(|r| { format!( "refused at event {} with {}: {}", r.index, r.error.class(), r.error ) })?; let saids: Vec = log .iter() .map(|e| KeyEvent::from_cbor(&e.bytes).map(|ev| ev.said().to_said_text())) .collect::>() .map_err(|e| e.to_string())?; let computed = serde_json::json!({ "controller": report.controller.to_text(), "event_saids": saids, "establishments": report.establishments.iter().map(|e| serde_json::json!({ "sequence": e.sequence, "keys": e.keys.iter().map(|k| hex::encode(&k.0)).collect::>(), "threshold": e.threshold.0, })).collect::>(), "next_key_digests": report.next_keys.iter().map(Said::to_said_text).collect::>(), "next_threshold": report.next_threshold.0, }); if *expected != computed { return Err(format!("expected: reference computes {computed}")); } Ok(Some(report)) } (None, Some(_)) => { let at = field(v, "error_at")? .as_u64() .ok_or("error_at is not an integer")?; match result { Ok(_) => Err(format!( "the log is accepted, but the vector says {}", text(v, "error")? )), Err(r) => { refused_as("the log verifier", Err(r.error.class()), v)?; if r.index as u64 != at { return Err(format!( "refused at event {}, but the vector says {at}", r.index )); } Ok(None) } } } _ => Err("a vector has exactly one of `expected` and `error`".into()), } } fn envelope(v: &S, logs: &BTreeMap) -> R { let mut keys = KeyBook::new(); for name in field(v, "key_event_logs")? .as_array() .ok_or("key_event_logs is not an array")? { let name = name .as_str() .ok_or("a key_event_logs entry is not a string")?; logs.get(name) .ok_or_else(|| format!("{name} is not an accepted log of kel_vectors.json"))? .add_to(&mut keys); } let bytes = hex_field(v, "cbor_hex")?; typed_matches_bytes(v, "envelope", &bytes)?; let result = Envelope::from_cbor(&bytes).and_then(|e| e.verify(&keys).map(|verified| (e, verified))); match (v.get("expected"), v.get("error")) { (Some(expected), None) => { let (e, verified) = result.map_err(|e| format!("refused with {}: {e}", e.class()))?; let computed = serde_json::json!({ "identity": verified.identity.to_said_text(), "snapshot": e.said().to_said_text(), "issuer": verified.issuer.to_text(), "owner": verified.owner.to_text(), "history_hash": hex::encode(verified.history_hash), "transfers": e.ownership_chain.len(), }); if *expected != computed { return Err(format!("expected: reference computes {computed}")); } Ok(()) } (None, Some(_)) => refused_as( "envelope verification", result.map(drop).map_err(|e| e.class()), v, ), _ => Err("a vector has exactly one of `expected` and `error`".into()), } } fn state_vector(v: &S) -> R { let genesis = field(v, "genesis")?; let bytes = hex_field(genesis, "cbor_hex")?; typed_matches_bytes(genesis, "state", &bytes)?; let mut state = State::from_cbor(&bytes).map_err(|e| format!("genesis: {e}"))?; if state.frame != 0 { return Err("a genesis state is at frame 0".into()); } same( "genesis state_hash", hex::encode(state.hash()), text(genesis, "state_hash")?, )?; for (i, frame) in field(v, "frames")? .as_array() .ok_or("frames is not an array")? .iter() .enumerate() { let at = |m: String| format!("frame {i}: {m}"); if field(frame, "frame")?.as_u64() != Some(state.frame) { return Err(at(format!( "the vector's frame number is not the state's, {}", state.frame ))); } let mut inputs = Vec::new(); for input in field(frame, "inputs")? .as_array() .ok_or("inputs is not an array")? { let bytes = hex_field(input, "cbor_hex")?; typed_matches_bytes(input, "input", &bytes).map_err(at)?; inputs.push(bytes); } let report = state.apply_frame(&inputs); let computed: Vec = report .results .iter() .map(|r| { let mut j = serde_json::json!({ "input": hex::encode(r.input), "class": r.class.map(|c| c.as_str()), }); match &r.outcome { Ok(()) => j["outcome"] = "applied".into(), Err(e) => { j["outcome"] = "dropped".into(); j["error"] = e.class().into(); } } j }) .collect(); if field(frame, "expected_results")? != &S::Array(computed.clone()) { return Err(at(format!( "expected_results: reference computes {}", S::Array(computed) ))); } let after = state.to_cbor(); typed_matches_bytes(frame, "expected_state", &after).map_err(at)?; same( "expected_state_cbor_hex", hex::encode(&after), text(frame, "expected_state_cbor_hex")?, ) .map_err(at)?; same( "expected_state_hash", hex::encode(state.hash()), text(frame, "expected_state_hash")?, ) .map_err(at)?; } Ok(()) } fn lockstep(v: &S) -> R { let n = field(v, "node_count")? .as_u64() .ok_or("node_count is not an integer")? as usize; let frame = field(v, "frame_number")? .as_u64() .ok_or("frame_number is not an integer")?; let reported_map = field(v, "node_reported_hashes")? .as_object() .ok_or("node_reported_hashes is not an object")?; let computed_map = field(v, "node_computed_hashes")? .as_object() .ok_or("node_computed_hashes is not an object")?; if reported_map.keys().ne(computed_map.keys()) { return Err("reported and computed hashes must name the same nodes".into()); } let mut reported = Vec::new(); for (node, h) in reported_map { reported.push((node.clone(), hash32(h, node)?)); } let public = tally(n, &reported).map_err(|e| e.to_string())?; same( "expected_threshold", supermajority_threshold(n).to_string(), &field(v, "expected_threshold")?.to_string(), )?; same( "expected_commit", public.committed().to_string(), &field(v, "expected_commit")?.to_string(), )?; let state = public.supermajority_hash.map(hex::encode); if field(v, "expected_state_hash")?.as_str() != state.as_deref() { return Err(format!("expected_state_hash: reference computes {state:?}")); } let outcomes = field(v, "expected_outcomes")? .as_object() .ok_or("expected_outcomes is not an object")?; let logs = field(v, "expected_dissent_logs")? .as_object() .ok_or("expected_dissent_logs is not an object")?; if outcomes.len() != reported.len() || logs.len() != reported.len() { return Err("expected_outcomes and expected_dissent_logs must name every node".into()); } for (node, computed) in computed_map { let computed = hash32(computed, node)?; let view = node_view(n, &reported, node, &computed).map_err(|e| e.to_string())?; let published = outcomes .get(node) .and_then(S::as_str) .ok_or_else(|| format!("no outcome for {node}"))?; same( &format!("{node}'s outcome"), view.outcome(&computed).as_str().to_owned(), published, )?; let log: Vec = view .dissent_entry(frame, &computed) .into_iter() .map(|e| { serde_json::json!({ "frame": e.frame, "dissenting_peers": e.dissenting_peers, "local_hash": hex::encode(e.local_hash), "supermajority_hash": hex::encode(e.supermajority_hash), }) }) .collect(); if logs.get(node) != Some(&S::Array(log.clone())) { return Err(format!( "{node}'s dissent log: reference computes {}", S::Array(log) )); } } Ok(()) } fn gossip(v: &S) -> R { let deliveries = field(v, "deliveries")? .as_array() .ok_or("deliveries is not an array")?; let expected = field(v, "expected_results")? .as_array() .ok_or("expected_results is not an array")?; if deliveries.len() != expected.len() { return Err("one expected result per delivery".into()); } let mut inbox = Inbox::new(); for (i, (d, want)) in deliveries.iter().zip(expected).enumerate() { let got = match inbox.deliver(text(d, "claimed_said")?, &hex_field(d, "cbor_hex")?) { Delivery::Accepted(s) => { serde_json::json!({"outcome": "accepted", "said": s.to_said_text()}) } Delivery::Duplicate(s) => { serde_json::json!({"outcome": "duplicate", "said": s.to_said_text()}) } Delivery::Rejected(e) => { serde_json::json!({"outcome": "rejected", "error": e.class()}) } }; if &got != want { return Err(format!( "delivery {i}: published {want}, reference computes {got}" )); } } let held: Vec = inbox .held() .iter() .map(|(s, b)| serde_json::json!({"said": s.to_said_text(), "cbor_hex": hex::encode(b)})) .collect(); if field(v, "expected_held")? != &S::Array(held.clone()) { return Err(format!("expected_held: reference holds {}", S::Array(held))); } Ok(()) }