From 0ca8e147389a859560fafd9d7c725b07081c2e6a Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Fri, 10 Jul 2026 21:23:14 +0200 Subject: [PATCH 01/21] feat(serder): NonCanonical error variant for the strict read path (#142) Co-Authored-By: Claude Fable 5 --- cesr/src/serder/error.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/cesr/src/serder/error.rs b/cesr/src/serder/error.rs index 036d87a..2cf8cb5 100644 --- a/cesr/src/serder/error.rs +++ b/cesr/src/serder/error.rs @@ -56,6 +56,20 @@ pub enum SerderError { source: ParsingError, }, + /// Input deviates from the fixed canonical event grammar at a specific + /// byte: whitespace, reordered/duplicate/unknown fields, string escapes, + /// or malformed framing. Canonical KERI event JSON is byte-deterministic, + /// so any deviation is rejected by construction. + #[error("non-canonical event JSON at byte {offset}: expected {expected}, found {found:?}")] + NonCanonical { + /// Byte offset in the raw input where the grammar was violated. + offset: usize, + /// What the grammar required at that offset. + expected: &'static str, + /// The byte actually found, or `None` at end of input. + found: Option, + }, + /// A version-string field's value does not fit its fixed-width hex /// encoding — rendering it anyway would widen the string and corrupt the /// 17-byte frame. @@ -67,9 +81,9 @@ pub enum SerderError { max: u32, }, - /// A serialization backend reported a slot layout inconsistent with the - /// bytes it rendered — a bug in the backend, surfaced as a typed error so - /// a corrupt frame can never escape. + /// A serialization backend or the canonical parser reported a slot layout + /// inconsistent with the bytes it rendered or parsed — an internal bug, + /// surfaced as a typed error so a corrupt frame can never escape. #[error("invalid event layout: {0}")] InvalidEventLayout(&'static str), From b6c10c484f2c0996dc02874ab3390b8c796d8efd Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Fri, 10 Jul 2026 21:35:30 +0200 Subject: [PATCH 02/21] feat(serder): strict canonical scanner core (#142) Co-Authored-By: Claude Fable 5 --- cesr/src/serder/deserialize.rs | 5 + cesr/src/serder/deserialize/canonical.rs | 263 +++++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 cesr/src/serder/deserialize/canonical.rs diff --git a/cesr/src/serder/deserialize.rs b/cesr/src/serder/deserialize.rs index 45218a2..0b67fb0 100644 --- a/cesr/src/serder/deserialize.rs +++ b/cesr/src/serder/deserialize.rs @@ -25,6 +25,11 @@ use crate::serder::primitives::to_qb64_string; use crate::serder::said::{compute_digest, said_placeholder}; use crate::serder::version::{SerKind, VERSION_STRING_LEN, VersionString}; +// Test-gated until the deserialize entry points adopt the strict parser +// (#142 rewire); the gate is removed when the first production caller lands. +#[cfg(test)] +pub(crate) mod canonical; + // --------------------------------------------------------------------------- // Public deserialization entry points // --------------------------------------------------------------------------- diff --git a/cesr/src/serder/deserialize/canonical.rs b/cesr/src/serder/deserialize/canonical.rs new file mode 100644 index 0000000..d0e1de2 --- /dev/null +++ b/cesr/src/serder/deserialize/canonical.rs @@ -0,0 +1,263 @@ +//! Strict single-pass parser for the five fixed canonical KERI event +//! grammars (`icp`, `rot`, `ixn`, `dip`, `drt`). +//! +//! Canonical event JSON is byte-deterministic: compact (no whitespace), +//! spec field order, and values that never require string escaping (qb64, +//! hex, ASCII constants — design §2.3 of the #79 write-up). This parser +//! accepts exactly that language, plus JSON integers for `kt`/`nt`/`bt` +//! (keripy `intive=True` emits them; their SAIDs are computed over the +//! integer form, so rejecting them would be a conformance gap). +//! +//! Every field is returned as a borrowed `&str`; the `d` (and `i` for +//! `icp`/`dip`) value byte spans are reported so SAID verification can +//! copy the raw once, overwrite the spans with `#`, and hash — the +//! read-path mirror of the write path's `EventLayout` slots. + +use core::ops::Range; +use core::str; + +use crate::serder::error::SerderError; + +/// A borrowed string value plus its byte span in the raw input. +#[derive(Debug)] +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) struct Spanned<'a> { + pub(crate) value: &'a str, + pub(crate) span: Range, +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) struct Scanner<'a> { + input: &'a [u8], + pos: usize, +} + +impl<'a> Scanner<'a> { + pub(crate) const fn new(input: &'a [u8]) -> Self { + Self { input, pos: 0 } + } + + fn err_at(&self, offset: usize, expected: &'static str) -> SerderError { + SerderError::NonCanonical { + offset, + expected, + found: self.input.get(offset).copied(), + } + } + + fn err(&self, expected: &'static str) -> SerderError { + self.err_at(self.pos, expected) + } + + fn peek(&self) -> Option { + self.input.get(self.pos).copied() + } + + /// Consume `lit` if it is next; report whether it was. + fn take_lit(&mut self, lit: &'static str) -> bool { + let Some(end) = self.pos.checked_add(lit.len()) else { + return false; + }; + if self.input.get(self.pos..end) == Some(lit.as_bytes()) { + self.pos = end; + true + } else { + false + } + } + + pub(crate) fn expect(&mut self, lit: &'static str) -> Result<(), SerderError> { + if self.take_lit(lit) { + Ok(()) + } else { + Err(self.err(lit)) + } + } + + fn advance(&mut self, by: usize, expected: &'static str) -> Result<(), SerderError> { + self.pos = self.pos.checked_add(by).ok_or_else(|| self.err(expected))?; + Ok(()) + } + + /// A canonical JSON string: no escapes, no control characters, UTF-8. + pub(crate) fn string(&mut self) -> Result, SerderError> { + self.expect("\"")?; + let start = self.pos; + loop { + match self.peek() { + Some(b'"') => break, + Some(b'\\') => { + return Err( + self.err("unescaped string byte (canonical values never require escaping)") + ); + } + Some(b) if b < 0x20 => { + return Err(self.err("unescaped string byte (no control characters)")); + } + Some(_) => self.advance(1, "string byte")?, + None => return Err(self.err("closing '\"'")), + } + } + let span = start..self.pos; + let bytes = self + .input + .get(span.clone()) + .ok_or(SerderError::InvalidEventLayout("string span out of bounds"))?; + let value = str::from_utf8(bytes).map_err(|_| self.err_at(start, "UTF-8 string value"))?; + self.expect("\"")?; + Ok(Spanned { value, span }) + } + + /// A canonical JSON integer: `0` or `[1-9][0-9]*`. No sign, no leading + /// zeros, no fraction or exponent. + pub(crate) fn integer(&mut self) -> Result<&'a str, SerderError> { + let start = self.pos; + match self.peek() { + Some(b'0') => { + self.advance(1, "digit")?; + if matches!(self.peek(), Some(b'0'..=b'9')) { + return Err(self.err("no leading zeros in canonical integer")); + } + } + Some(b'1'..=b'9') => { + self.advance(1, "digit")?; + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.advance(1, "digit")?; + } + } + _ => return Err(self.err("digit")), + } + let bytes = self + .input + .get(start..self.pos) + .ok_or(SerderError::InvalidEventLayout( + "integer span out of bounds", + ))?; + str::from_utf8(bytes).map_err(|_| self.err_at(start, "ASCII integer")) + } + + /// The input must be fully consumed. + pub(crate) fn finish(&self) -> Result<(), SerderError> { + if self.pos == self.input.len() { + Ok(()) + } else { + Err(self.err("end of input")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn non_canonical_at(e: &SerderError) -> Option<(usize, &'static str)> { + if let SerderError::NonCanonical { + offset, expected, .. + } = e + { + Some((*offset, expected)) + } else { + None + } + } + + #[test] + fn scanner_string_reads_value_and_span() { + let mut sc = Scanner::new(b"\"abc\"rest"); + let s = sc.string().unwrap(); + assert_eq!(s.value, "abc"); + assert_eq!(s.span, 1..4); + assert_eq!(sc.pos, 5); + } + + #[test] + fn scanner_string_rejects_escape() { + let mut sc = Scanner::new(b"\"a\\u0030\""); + let err = sc.string().unwrap_err(); + let (offset, _) = non_canonical_at(&err).expect("NonCanonical"); + assert_eq!(offset, 2, "the backslash byte is the violation"); + } + + #[test] + fn scanner_string_rejects_control_char() { + let mut sc = Scanner::new(b"\"a\x01b\""); + assert!(matches!( + sc.string(), + Err(SerderError::NonCanonical { offset: 2, .. }) + )); + } + + #[test] + fn scanner_string_rejects_unterminated() { + let mut sc = Scanner::new(b"\"abc"); + assert!(matches!( + sc.string(), + Err(SerderError::NonCanonical { + offset: 4, + found: None, + .. + }) + )); + } + + #[test] + fn scanner_string_rejects_non_utf8() { + let mut sc = Scanner::new(b"\"\xFF\xFE\""); + assert!(matches!( + sc.string(), + Err(SerderError::NonCanonical { offset: 1, .. }) + )); + } + + #[test] + fn scanner_string_accepts_multibyte_utf8() { + let mut sc = Scanner::new("\"héllo\"".as_bytes()); + assert_eq!(sc.string().unwrap().value, "héllo"); + } + + #[test] + fn scanner_integer_grammar() { + assert_eq!(Scanner::new(b"0,").integer().unwrap(), "0"); + assert_eq!(Scanner::new(b"10}").integer().unwrap(), "10"); + assert!(Scanner::new(b"01").integer().is_err(), "leading zero"); + assert!(Scanner::new(b"-1").integer().is_err(), "sign"); + assert!(Scanner::new(b"x").integer().is_err(), "non-digit"); + } + + #[test] + fn scanner_expect_reports_offset_and_found() { + let mut sc = Scanner::new(b"abc"); + let err = sc.expect("abd").unwrap_err(); + assert!(matches!( + err, + SerderError::NonCanonical { + offset: 0, + found: Some(b'a'), + .. + } + )); + } + + #[test] + fn scanner_finish_rejects_trailing() { + let mut sc = Scanner::new(b"ab"); + sc.expect("ab").unwrap(); + sc.finish().unwrap(); + let mut sc2 = Scanner::new(b"abX"); + sc2.expect("ab").unwrap(); + assert!(matches!( + sc2.finish(), + Err(SerderError::NonCanonical { + offset: 2, + found: Some(b'X'), + .. + }) + )); + } +} From 46882647ee00e6bdf2c7996727b3f8ef867c0a55 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Fri, 10 Jul 2026 21:48:23 +0200 Subject: [PATCH 03/21] =?UTF-8?q?fix(serder):=20canonical=20scanner=20revi?= =?UTF-8?q?ew=20fixes=20=E2=80=94=20precise=20UTF-8=20offset,=20property?= =?UTF-8?q?=20tests=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- cesr/src/serder/deserialize/canonical.rs | 95 +++++++++++++++++++++++- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/cesr/src/serder/deserialize/canonical.rs b/cesr/src/serder/deserialize/canonical.rs index d0e1de2..b9e3f3d 100644 --- a/cesr/src/serder/deserialize/canonical.rs +++ b/cesr/src/serder/deserialize/canonical.rs @@ -72,6 +72,8 @@ impl<'a> Scanner<'a> { } } + /// On mismatch the error reports the literal's START offset with the byte + /// found there; the `expected` field carries the whole literal. pub(crate) fn expect(&mut self, lit: &'static str) -> Result<(), SerderError> { if self.take_lit(lit) { Ok(()) @@ -109,7 +111,12 @@ impl<'a> Scanner<'a> { .input .get(span.clone()) .ok_or(SerderError::InvalidEventLayout("string span out of bounds"))?; - let value = str::from_utf8(bytes).map_err(|_| self.err_at(start, "UTF-8 string value"))?; + let value = str::from_utf8(bytes).map_err(|e| { + start.checked_add(e.valid_up_to()).map_or_else( + || SerderError::InvalidEventLayout("UTF-8 error offset overflow"), + |offset| self.err_at(offset, "UTF-8 string value"), + ) + })?; self.expect("\"")?; Ok(Spanned { value, span }) } @@ -139,6 +146,7 @@ impl<'a> Scanner<'a> { .ok_or(SerderError::InvalidEventLayout( "integer span out of bounds", ))?; + // Defensively unreachable: every scanned byte is 0x30–0x39 by construction. str::from_utf8(bytes).map_err(|_| self.err_at(start, "ASCII integer")) } @@ -215,10 +223,45 @@ mod tests { )); } + #[test] + fn scanner_string_utf8_error_reports_violating_byte() { + let mut sc = Scanner::new(b"\"ab\xFF\""); + assert!(matches!( + sc.string(), + Err(SerderError::NonCanonical { + offset: 3, + found: Some(0xFF), + .. + }) + )); + } + #[test] fn scanner_string_accepts_multibyte_utf8() { - let mut sc = Scanner::new("\"héllo\"".as_bytes()); - assert_eq!(sc.string().unwrap().value, "héllo"); + let input = "\"héllo\"".as_bytes(); + let mut sc = Scanner::new(input); + let s = sc.string().unwrap(); + assert_eq!(s.value, "héllo"); + assert_eq!(s.span, 1..7); + assert_eq!(&input[s.span.clone()], s.value.as_bytes()); + } + + #[test] + fn scanner_string_empty_input_and_empty_value() { + let mut sc = Scanner::new(b""); + assert!(matches!( + sc.string(), + Err(SerderError::NonCanonical { + offset: 0, + found: None, + .. + }) + )); + let mut sc2 = Scanner::new(b"\"\""); + let s = sc2.string().unwrap(); + assert_eq!(s.value, ""); + assert_eq!(s.span, 1..1); + sc2.finish().unwrap(); } #[test] @@ -230,6 +273,22 @@ mod tests { assert!(Scanner::new(b"x").integer().is_err(), "non-digit"); } + #[test] + fn scanner_integer_boundaries() { + let mut empty = Scanner::new(b""); + assert!(matches!( + empty.integer(), + Err(SerderError::NonCanonical { + offset: 0, + found: None, + .. + }) + )); + let mut eof_terminated = Scanner::new(b"907"); + assert_eq!(eof_terminated.integer().unwrap(), "907"); + eof_terminated.finish().unwrap(); + } + #[test] fn scanner_expect_reports_offset_and_found() { let mut sc = Scanner::new(b"abc"); @@ -260,4 +319,34 @@ mod tests { }) )); } + + mod properties { + use super::*; + use proptest::prelude::*; + + proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// A scanner over untrusted bytes must never panic, whatever the + /// input — every failure is a typed error. + #[test] + fn scanner_never_panics(input in proptest::collection::vec(any::(), 0..64)) { + let _ = Scanner::new(&input).string(); + let _ = Scanner::new(&input).integer(); + let mut sc = Scanner::new(&input); + let _ = sc.expect("{\"v\":\""); + let _ = sc.finish(); + } + + /// Load-bearing invariant: an accepted string's span addresses + /// exactly its value bytes in the raw input (SAID verification + /// overwrites raw[span] later). + #[test] + fn accepted_string_span_addresses_value(input in proptest::collection::vec(any::(), 0..64)) { + if let Ok(s) = Scanner::new(&input).string() { + prop_assert_eq!(&input[s.span.clone()], s.value.as_bytes()); + } + } + } + } } From f67f9f9feb12f64aeedbbddeae020369e9084f69 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Fri, 10 Jul 2026 21:54:21 +0200 Subject: [PATCH 04/21] =?UTF-8?q?feat(serder):=20canonical=20value=20gramm?= =?UTF-8?q?ars=20=E2=80=94=20arrays,=20tholder,=20count,=20seal=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- cesr/src/serder/deserialize/canonical.rs | 296 +++++++++++++++++++++++ 1 file changed, 296 insertions(+) diff --git a/cesr/src/serder/deserialize/canonical.rs b/cesr/src/serder/deserialize/canonical.rs index b9e3f3d..845cb17 100644 --- a/cesr/src/serder/deserialize/canonical.rs +++ b/cesr/src/serder/deserialize/canonical.rs @@ -13,6 +13,12 @@ //! copy the raw once, overwrite the spans with `#`, and hash — the //! read-path mirror of the write path's `EventLayout` slots. +#[cfg(feature = "alloc")] +#[allow( + unused_imports, + reason = "alloc prelude items; subset used per cfg/feature combination" +)] +use alloc::{borrow::ToOwned, format, string::String, string::ToString, vec, vec::Vec}; use core::ops::Range; use core::str; @@ -29,6 +35,74 @@ pub(crate) struct Spanned<'a> { pub(crate) span: Range, } +/// A `kt`/`nt` threshold value as it appears on the wire. +#[derive(Debug)] +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) enum ParsedTholder<'a> { + /// Hex string form, e.g. `"1"`, `"a"`. + Hex(&'a str), + /// keripy `intive=True` integer form, e.g. `1`. + Number(&'a str), + /// Weighted clauses; a flat array is normalized to a single clause. + Weighted(Vec>), +} + +/// A `bt` witness-threshold value as it appears on the wire. +#[derive(Debug)] +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) enum ParsedCount<'a> { + /// Hex string form. + Hex(&'a str), + /// keripy `intive=True` integer form. + Number(&'a str), +} + +/// A seal object, one of the five fixed shapes. +#[derive(Debug)] +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) enum ParsedSeal<'a> { + /// Event digest seal. + Digest { + /// SAID digest, qb64. + d: &'a str, + }, + /// Merkle tree root seal. + Root { + /// Root digest, qb64. + rd: &'a str, + }, + /// Delegation source seal. + Source { + /// Sequence number, hex. + s: &'a str, + /// SAID digest of the delegating event, qb64. + d: &'a str, + }, + /// Full key event seal. + Event { + /// Identifier prefix, qb64. + i: &'a str, + /// Sequence number, hex. + s: &'a str, + /// SAID digest, qb64. + d: &'a str, + }, + /// Last-establishment-event seal. + Last { + /// Identifier prefix, qb64. + i: &'a str, + }, +} + #[allow( clippy::redundant_pub_crate, reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" @@ -160,6 +234,119 @@ impl<'a> Scanner<'a> { } } +fn string_array<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> { + sc.expect("[")?; + let mut items = Vec::new(); + if sc.take_lit("]") { + return Ok(items); + } + loop { + items.push(sc.string()?.value); + if sc.take_lit("]") { + return Ok(items); + } + sc.expect(",")?; + } +} + +fn tholder<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> { + match sc.peek() { + Some(b'"') => Ok(ParsedTholder::Hex(sc.string()?.value)), + Some(b'0'..=b'9') => Ok(ParsedTholder::Number(sc.integer()?)), + Some(b'[') => weighted(sc), + _ => Err(sc.err("threshold (hex string, integer, or weighted array)")), + } +} + +fn weighted<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> { + sc.expect("[")?; + if sc.take_lit("]") { + return Ok(ParsedTholder::Weighted(Vec::new())); + } + match sc.peek() { + Some(b'"') => { + let mut clause = Vec::new(); + loop { + clause.push(sc.string()?.value); + if sc.take_lit("]") { + return Ok(ParsedTholder::Weighted(vec![clause])); + } + sc.expect(",")?; + } + } + Some(b'[') => { + let mut clauses = Vec::new(); + loop { + clauses.push(string_array(sc)?); + if sc.take_lit("]") { + return Ok(ParsedTholder::Weighted(clauses)); + } + sc.expect(",")?; + } + } + _ => Err(sc.err("weight fraction string or clause array")), + } +} + +fn count<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> { + match sc.peek() { + Some(b'"') => Ok(ParsedCount::Hex(sc.string()?.value)), + Some(b'0'..=b'9') => Ok(ParsedCount::Number(sc.integer()?)), + _ => Err(sc.err("count (hex string or integer)")), + } +} + +/// One seal object. Field order per variant is fixed (matches the writer +/// and keripy's namedtuple serialization order). +fn seal<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> { + sc.expect("{")?; + if sc.take_lit("\"d\":") { + let d = sc.string()?.value; + sc.expect("}")?; + return Ok(ParsedSeal::Digest { d }); + } + if sc.take_lit("\"rd\":") { + let rd = sc.string()?.value; + sc.expect("}")?; + return Ok(ParsedSeal::Root { rd }); + } + if sc.take_lit("\"s\":") { + let s = sc.string()?.value; + sc.expect(",\"d\":")?; + let d = sc.string()?.value; + sc.expect("}")?; + return Ok(ParsedSeal::Source { s, d }); + } + if sc.take_lit("\"i\":") { + let i = sc.string()?.value; + if sc.take_lit("}") { + return Ok(ParsedSeal::Last { i }); + } + sc.expect(",\"s\":")?; + let s = sc.string()?.value; + sc.expect(",\"d\":")?; + let d = sc.string()?.value; + sc.expect("}")?; + return Ok(ParsedSeal::Event { i, s, d }); + } + Err(sc.err("seal object key (\"d\", \"rd\", \"s\", or \"i\")")) +} + +fn seal_array<'a>(sc: &mut Scanner<'a>) -> Result>, SerderError> { + sc.expect("[")?; + let mut items = Vec::new(); + if sc.take_lit("]") { + return Ok(items); + } + loop { + items.push(seal(sc)?); + if sc.take_lit("]") { + return Ok(items); + } + sc.expect(",")?; + } +} + #[cfg(test)] mod tests { use super::*; @@ -320,6 +507,110 @@ mod tests { )); } + #[test] + fn string_array_shapes() { + assert!(string_array(&mut Scanner::new(b"[]")).unwrap().is_empty()); + assert_eq!( + string_array(&mut Scanner::new(b"[\"a\",\"b\"]")).unwrap(), + vec!["a", "b"] + ); + assert!( + string_array(&mut Scanner::new(b"[\"a\",]")).is_err(), + "trailing comma" + ); + assert!( + string_array(&mut Scanner::new(b"[ \"a\"]")).is_err(), + "whitespace" + ); + } + + #[test] + fn tholder_shapes() { + assert!(matches!( + tholder(&mut Scanner::new(b"\"a\"")).unwrap(), + ParsedTholder::Hex("a") + )); + assert!(matches!( + tholder(&mut Scanner::new(b"2,")).unwrap(), + ParsedTholder::Number("2") + )); + let ParsedTholder::Weighted(flat) = + tholder(&mut Scanner::new(b"[\"1/2\",\"1/2\"]")).unwrap() + else { + unreachable!() + }; + assert_eq!(flat, vec![vec!["1/2", "1/2"]]); + let ParsedTholder::Weighted(nested) = + tholder(&mut Scanner::new(b"[[\"1/2\",\"1/2\"],[\"1\"]]")).unwrap() + else { + unreachable!() + }; + assert_eq!(nested, vec![vec!["1/2", "1/2"], vec!["1"]]); + let ParsedTholder::Weighted(empty) = tholder(&mut Scanner::new(b"[]")).unwrap() else { + unreachable!() + }; + assert!(empty.is_empty()); + assert!(tholder(&mut Scanner::new(b"true")).is_err()); + } + + #[test] + fn count_shapes() { + assert!(matches!( + count(&mut Scanner::new(b"\"0\"")).unwrap(), + ParsedCount::Hex("0") + )); + assert!(matches!( + count(&mut Scanner::new(b"3,")).unwrap(), + ParsedCount::Number("3") + )); + assert!(count(&mut Scanner::new(b"[]")).is_err()); + } + + #[test] + fn seal_shapes() { + assert!(matches!( + seal(&mut Scanner::new(b"{\"d\":\"X\"}")).unwrap(), + ParsedSeal::Digest { d: "X" } + )); + assert!(matches!( + seal(&mut Scanner::new(b"{\"rd\":\"X\"}")).unwrap(), + ParsedSeal::Root { rd: "X" } + )); + assert!(matches!( + seal(&mut Scanner::new(b"{\"s\":\"1\",\"d\":\"X\"}")).unwrap(), + ParsedSeal::Source { s: "1", d: "X" } + )); + assert!(matches!( + seal(&mut Scanner::new(b"{\"i\":\"I\",\"s\":\"1\",\"d\":\"X\"}")).unwrap(), + ParsedSeal::Event { + i: "I", + s: "1", + d: "X" + } + )); + assert!(matches!( + seal(&mut Scanner::new(b"{\"i\":\"I\"}")).unwrap(), + ParsedSeal::Last { i: "I" } + )); + assert!( + seal(&mut Scanner::new(b"{\"d\":\"X\",\"s\":\"1\"}")).is_err(), + "out-of-order seal fields are non-canonical" + ); + assert!( + seal(&mut Scanner::new(b"{\"x\":\"X\"}")).is_err(), + "unknown seal key" + ); + } + + #[test] + fn seal_array_shapes() { + assert!(seal_array(&mut Scanner::new(b"[]")).unwrap().is_empty()); + let seals = seal_array(&mut Scanner::new(b"[{\"d\":\"X\"},{\"i\":\"I\"}]")).unwrap(); + assert_eq!(seals.len(), 2); + assert!(matches!(seals[0], ParsedSeal::Digest { d: "X" })); + assert!(matches!(seals[1], ParsedSeal::Last { i: "I" })); + } + mod properties { use super::*; use proptest::prelude::*; @@ -336,6 +627,11 @@ mod tests { let mut sc = Scanner::new(&input); let _ = sc.expect("{\"v\":\""); let _ = sc.finish(); + let _ = string_array(&mut Scanner::new(&input)); + let _ = tholder(&mut Scanner::new(&input)); + let _ = count(&mut Scanner::new(&input)); + let _ = seal(&mut Scanner::new(&input)); + let _ = seal_array(&mut Scanner::new(&input)); } /// Load-bearing invariant: an accepted string's span addresses From 6fa4a6715ce78c12157cfb5cafdf2ab3e0176f4c Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Fri, 10 Jul 2026 22:03:19 +0200 Subject: [PATCH 05/21] refactor(serder): dedupe canonical array grammar loops (#142) Co-Authored-By: Claude Fable 5 --- cesr/src/serder/deserialize/canonical.rs | 75 +++++++++++++----------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/cesr/src/serder/deserialize/canonical.rs b/cesr/src/serder/deserialize/canonical.rs index 845cb17..254d489 100644 --- a/cesr/src/serder/deserialize/canonical.rs +++ b/cesr/src/serder/deserialize/canonical.rs @@ -234,19 +234,38 @@ impl<'a> Scanner<'a> { } } -fn string_array<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> { - sc.expect("[")?; - let mut items = Vec::new(); - if sc.take_lit("]") { - return Ok(items); - } +/// Items of a canonical JSON array after the opening `[` and the empty-array +/// check (`]`) have already been consumed — i.e. the cursor is positioned at +/// the first item. +fn tail_list<'a, T>( + sc: &mut Scanner<'a>, + mut item: impl FnMut(&mut Scanner<'a>) -> Result, +) -> Result, SerderError> { + let mut items = vec![item(sc)?]; loop { - items.push(sc.string()?.value); if sc.take_lit("]") { return Ok(items); } sc.expect(",")?; + items.push(item(sc)?); + } +} + +/// A canonical JSON array `[item,item,...]` — no whitespace, no trailing +/// comma; empty `[]` allowed. +fn delimited_list<'a, T>( + sc: &mut Scanner<'a>, + item: impl FnMut(&mut Scanner<'a>) -> Result, +) -> Result, SerderError> { + sc.expect("[")?; + if sc.take_lit("]") { + return Ok(Vec::new()); } + tail_list(sc, item) +} + +fn string_array<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> { + delimited_list(sc, |s| s.string().map(|sp| sp.value)) } fn tholder<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> { @@ -265,24 +284,12 @@ fn weighted<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> } match sc.peek() { Some(b'"') => { - let mut clause = Vec::new(); - loop { - clause.push(sc.string()?.value); - if sc.take_lit("]") { - return Ok(ParsedTholder::Weighted(vec![clause])); - } - sc.expect(",")?; - } + let clause = tail_list(sc, |s| s.string().map(|sp| sp.value))?; + Ok(ParsedTholder::Weighted(vec![clause])) } Some(b'[') => { - let mut clauses = Vec::new(); - loop { - clauses.push(string_array(sc)?); - if sc.take_lit("]") { - return Ok(ParsedTholder::Weighted(clauses)); - } - sc.expect(",")?; - } + let clauses = tail_list(sc, string_array)?; + Ok(ParsedTholder::Weighted(clauses)) } _ => Err(sc.err("weight fraction string or clause array")), } @@ -333,18 +340,7 @@ fn seal<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> { } fn seal_array<'a>(sc: &mut Scanner<'a>) -> Result>, SerderError> { - sc.expect("[")?; - let mut items = Vec::new(); - if sc.take_lit("]") { - return Ok(items); - } - loop { - items.push(seal(sc)?); - if sc.take_lit("]") { - return Ok(items); - } - sc.expect(",")?; - } + delimited_list(sc, seal) } #[cfg(test)] @@ -602,6 +598,15 @@ mod tests { ); } + #[test] + fn weighted_rejects_non_string_non_array_element() { + let mut sc = Scanner::new(b"[true]"); + assert!(matches!( + weighted(&mut sc), + Err(SerderError::NonCanonical { offset: 1, .. }) + )); + } + #[test] fn seal_array_shapes() { assert!(seal_array(&mut Scanner::new(b"[]")).unwrap().is_empty()); From 34707f843d28161f20f259e3193a0063704166ac Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Fri, 10 Jul 2026 22:08:03 +0200 Subject: [PATCH 06/21] feat(serder): per-ilk strict grammars with fixed-offset version validation (#142) Co-Authored-By: Claude Fable 5 --- cesr/src/serder/deserialize/canonical.rs | 730 +++++++++++++++++++++++ 1 file changed, 730 insertions(+) diff --git a/cesr/src/serder/deserialize/canonical.rs b/cesr/src/serder/deserialize/canonical.rs index 254d489..aa75599 100644 --- a/cesr/src/serder/deserialize/canonical.rs +++ b/cesr/src/serder/deserialize/canonical.rs @@ -23,6 +23,7 @@ use core::ops::Range; use core::str; use crate::serder::error::SerderError; +use crate::serder::version::{SerKind, VERSION_STRING_LEN, VersionString}; /// A borrowed string value plus its byte span in the raw input. #[derive(Debug)] @@ -103,6 +104,121 @@ pub(crate) enum ParsedSeal<'a> { }, } +/// A parsed inception (`icp`) body: borrowed field views plus SAID spans. +#[derive(Debug)] +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) struct ParsedIcp<'a> { + /// The `d` field: SAID digest, value and byte span. + pub(crate) said: Spanned<'a>, + /// The `i` field: identifier prefix, value and byte span. + pub(crate) prefix: Spanned<'a>, + /// The `s` field: sequence number, hex. + pub(crate) sn: &'a str, + /// The `kt` field: signing threshold. + pub(crate) threshold: ParsedTholder<'a>, + /// The `k` field: signing keys, qb64. + pub(crate) keys: Vec<&'a str>, + /// The `nt` field: next signing threshold. + pub(crate) next_threshold: ParsedTholder<'a>, + /// The `n` field: next key digests, qb64. + pub(crate) next_keys: Vec<&'a str>, + /// The `bt` field: witness threshold. + pub(crate) witness_threshold: ParsedCount<'a>, + /// The `b` field: witness identifiers, qb64. + pub(crate) witnesses: Vec<&'a str>, + /// The `c` field: configuration traits. + pub(crate) config: Vec<&'a str>, + /// The `a` field: anchored seals. + pub(crate) anchors: Vec>, +} + +/// A parsed delegated inception (`dip`): an inception plus the delegator. +#[derive(Debug)] +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) struct ParsedDip<'a> { + /// The inception fields shared with `icp`. + pub(crate) icp: ParsedIcp<'a>, + /// The `di` field: delegator identifier, qb64. + pub(crate) delegator: &'a str, +} + +/// A parsed rotation (`rot`) or delegated rotation (`drt`) body. +#[derive(Debug)] +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) struct ParsedRot<'a> { + /// The `d` field: SAID digest, value and byte span. + pub(crate) said: Spanned<'a>, + /// The `i` field: identifier prefix, qb64. + pub(crate) prefix: &'a str, + /// The `s` field: sequence number, hex. + pub(crate) sn: &'a str, + /// The `p` field: prior event SAID, qb64. + pub(crate) prior: &'a str, + /// The `kt` field: signing threshold. + pub(crate) threshold: ParsedTholder<'a>, + /// The `k` field: signing keys, qb64. + pub(crate) keys: Vec<&'a str>, + /// The `nt` field: next signing threshold. + pub(crate) next_threshold: ParsedTholder<'a>, + /// The `n` field: next key digests, qb64. + pub(crate) next_keys: Vec<&'a str>, + /// The `bt` field: witness threshold. + pub(crate) witness_threshold: ParsedCount<'a>, + /// The `br` field: witness removals, qb64. + pub(crate) witness_removals: Vec<&'a str>, + /// The `ba` field: witness additions, qb64. + pub(crate) witness_additions: Vec<&'a str>, + /// The `a` field: anchored seals. + pub(crate) anchors: Vec>, +} + +/// A parsed interaction (`ixn`) body. +#[derive(Debug)] +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) struct ParsedIxn<'a> { + /// The `d` field: SAID digest, value and byte span. + pub(crate) said: Spanned<'a>, + /// The `i` field: identifier prefix, qb64. + pub(crate) prefix: &'a str, + /// The `s` field: sequence number, hex. + pub(crate) sn: &'a str, + /// The `p` field: prior event SAID, qb64. + pub(crate) prior: &'a str, + /// The `a` field: anchored seals. + pub(crate) anchors: Vec>, +} + +/// Any parsed event, dispatched on the wire ilk. +#[derive(Debug)] +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) enum ParsedEvent<'a> { + /// `icp`. + Inception(ParsedIcp<'a>), + /// `rot`. + Rotation(ParsedRot<'a>), + /// `ixn`. + Interaction(ParsedIxn<'a>), + /// `dip`. + DelegatedInception(ParsedDip<'a>), + /// `drt`. + DelegatedRotation(ParsedRot<'a>), +} + #[allow( clippy::redundant_pub_crate, reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" @@ -343,9 +459,292 @@ fn seal_array<'a>(sc: &mut Scanner<'a>) -> Result>, SerderErr delimited_list(sc, seal) } +/// Parse and validate the fixed head `{"v":"<17-byte version string>","t":` +/// and return the scanner positioned after the ilk value, plus the ilk. +fn head(raw: &[u8]) -> Result<(Scanner<'_>, Spanned<'_>), SerderError> { + let mut sc = Scanner::new(raw); + sc.expect("{\"v\":\"")?; + let vs_start = sc.pos; + let vs_end = vs_start + .checked_add(VERSION_STRING_LEN) + .ok_or(SerderError::InvalidEventLayout("version span overflow"))?; + let vs_bytes = raw + .get(vs_start..vs_end) + .ok_or_else(|| sc.err("17-byte version string"))?; + let vs_str = str::from_utf8(vs_bytes).map_err(|_| sc.err("ASCII version string"))?; + let vs = VersionString::parse(vs_str)?; + if vs.kind != SerKind::Json { + return Err(SerderError::InvalidVersionString(format!( + "expected JSON, got {}", + vs.kind.as_str() + ))); + } + let expected_size = + usize::try_from(vs.size).map_err(|e| SerderError::InvalidVersionString(e.to_string()))?; + if expected_size != raw.len() { + return Err(SerderError::InvalidVersionString(format!( + "version string size {} does not match actual size {}", + expected_size, + raw.len() + ))); + } + sc.pos = vs_end; + sc.expect("\",\"t\":")?; + let ilk = sc.string()?; + Ok((sc, ilk)) +} + +fn icp_fields<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> { + sc.expect(",\"d\":")?; + let said = sc.string()?; + sc.expect(",\"i\":")?; + let prefix = sc.string()?; + sc.expect(",\"s\":")?; + let sn = sc.string()?.value; + sc.expect(",\"kt\":")?; + let threshold = tholder(sc)?; + sc.expect(",\"k\":")?; + let keys = string_array(sc)?; + sc.expect(",\"nt\":")?; + let next_threshold = tholder(sc)?; + sc.expect(",\"n\":")?; + let next_keys = string_array(sc)?; + sc.expect(",\"bt\":")?; + let witness_threshold = count(sc)?; + sc.expect(",\"b\":")?; + let witnesses = string_array(sc)?; + sc.expect(",\"c\":")?; + let config = string_array(sc)?; + sc.expect(",\"a\":")?; + let anchors = seal_array(sc)?; + Ok(ParsedIcp { + said, + prefix, + sn, + threshold, + keys, + next_threshold, + next_keys, + witness_threshold, + witnesses, + config, + anchors, + }) +} + +fn icp_body(mut sc: Scanner<'_>) -> Result, SerderError> { + let fields = icp_fields(&mut sc)?; + sc.expect("}")?; + sc.finish()?; + Ok(fields) +} + +fn dip_body(mut sc: Scanner<'_>) -> Result, SerderError> { + let icp = icp_fields(&mut sc)?; + sc.expect(",\"di\":")?; + let delegator = sc.string()?.value; + sc.expect("}")?; + sc.finish()?; + Ok(ParsedDip { icp, delegator }) +} + +fn rot_body(mut sc: Scanner<'_>) -> Result, SerderError> { + sc.expect(",\"d\":")?; + let said = sc.string()?; + sc.expect(",\"i\":")?; + let prefix = sc.string()?.value; + sc.expect(",\"s\":")?; + let sn = sc.string()?.value; + sc.expect(",\"p\":")?; + let prior = sc.string()?.value; + sc.expect(",\"kt\":")?; + let threshold = tholder(&mut sc)?; + sc.expect(",\"k\":")?; + let keys = string_array(&mut sc)?; + sc.expect(",\"nt\":")?; + let next_threshold = tholder(&mut sc)?; + sc.expect(",\"n\":")?; + let next_keys = string_array(&mut sc)?; + sc.expect(",\"bt\":")?; + let witness_threshold = count(&mut sc)?; + sc.expect(",\"br\":")?; + let witness_removals = string_array(&mut sc)?; + sc.expect(",\"ba\":")?; + let witness_additions = string_array(&mut sc)?; + sc.expect(",\"a\":")?; + let anchors = seal_array(&mut sc)?; + sc.expect("}")?; + sc.finish()?; + Ok(ParsedRot { + said, + prefix, + sn, + prior, + threshold, + keys, + next_threshold, + next_keys, + witness_threshold, + witness_removals, + witness_additions, + anchors, + }) +} + +fn ixn_body(mut sc: Scanner<'_>) -> Result, SerderError> { + sc.expect(",\"d\":")?; + let said = sc.string()?; + sc.expect(",\"i\":")?; + let prefix = sc.string()?.value; + sc.expect(",\"s\":")?; + let sn = sc.string()?.value; + sc.expect(",\"p\":")?; + let prior = sc.string()?.value; + sc.expect(",\"a\":")?; + let anchors = seal_array(&mut sc)?; + sc.expect("}")?; + sc.finish()?; + Ok(ParsedIxn { + said, + prefix, + sn, + prior, + anchors, + }) +} + +fn require_ilk( + sc: &Scanner<'_>, + ilk: &Spanned<'_>, + expected: &'static str, +) -> Result<(), SerderError> { + if ilk.value == expected { + Ok(()) + } else { + Err(sc.err_at(ilk.span.start, expected)) + } +} + +/// Parse any of the five fixed canonical event grammars, dispatched on the +/// wire `t` (ilk) field. +/// +/// # Errors +/// +/// Returns [`SerderError::NonCanonical`] if the input deviates from the +/// strict grammar, [`SerderError::InvalidVersionString`] if the version +/// header is malformed or its size does not match the input length, or +/// [`SerderError::UnknownIlk`] if `t` is not one of `icp`/`rot`/`ixn`/`dip`/`drt`. +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn parse_event(raw: &[u8]) -> Result, SerderError> { + let (sc, ilk) = head(raw)?; + match ilk.value { + "icp" => Ok(ParsedEvent::Inception(icp_body(sc)?)), + "rot" => Ok(ParsedEvent::Rotation(rot_body(sc)?)), + "ixn" => Ok(ParsedEvent::Interaction(ixn_body(sc)?)), + "dip" => Ok(ParsedEvent::DelegatedInception(dip_body(sc)?)), + "drt" => Ok(ParsedEvent::DelegatedRotation(rot_body(sc)?)), + other => Err(SerderError::UnknownIlk(other.to_owned())), + } +} + +/// Parse a strict canonical `icp` body. +/// +/// # Errors +/// +/// See [`parse_event`]. Additionally returns [`SerderError::NonCanonical`] +/// if the wire `t` field is not `"icp"`. +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn parse_inception(raw: &[u8]) -> Result, SerderError> { + let (sc, ilk) = head(raw)?; + require_ilk(&sc, &ilk, "icp")?; + icp_body(sc) +} + +/// Parse a strict canonical `rot` body. +/// +/// # Errors +/// +/// See [`parse_event`]. Additionally returns [`SerderError::NonCanonical`] +/// if the wire `t` field is not `"rot"`. +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn parse_rotation(raw: &[u8]) -> Result, SerderError> { + let (sc, ilk) = head(raw)?; + require_ilk(&sc, &ilk, "rot")?; + rot_body(sc) +} + +/// Parse a strict canonical `ixn` body. +/// +/// # Errors +/// +/// See [`parse_event`]. Additionally returns [`SerderError::NonCanonical`] +/// if the wire `t` field is not `"ixn"`. +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn parse_interaction(raw: &[u8]) -> Result, SerderError> { + let (sc, ilk) = head(raw)?; + require_ilk(&sc, &ilk, "ixn")?; + ixn_body(sc) +} + +/// Parse a strict canonical `dip` body. +/// +/// # Errors +/// +/// See [`parse_event`]. Additionally returns [`SerderError::NonCanonical`] +/// if the wire `t` field is not `"dip"`. +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn parse_delegated_inception(raw: &[u8]) -> Result, SerderError> { + let (sc, ilk) = head(raw)?; + require_ilk(&sc, &ilk, "dip")?; + dip_body(sc) +} + +/// Parse a strict canonical `drt` body. +/// +/// # Errors +/// +/// See [`parse_event`]. Additionally returns [`SerderError::NonCanonical`] +/// if the wire `t` field is not `"drt"`. +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn parse_delegated_rotation(raw: &[u8]) -> Result, SerderError> { + let (sc, ilk) = head(raw)?; + require_ilk(&sc, &ilk, "drt")?; + rot_body(sc) +} + #[cfg(test)] mod tests { use super::*; + use crate::core::matter::builder::MatterBuilder; + use crate::core::matter::code::{DigestCode, VerKeyCode}; + use crate::core::primitives::{Prefixer, Saider, Seqner, Tholder, Verfer}; + use crate::keri::{ + ConfigTrait, DelegatedInceptionEvent, DelegatedRotationEvent, Identifier, InceptionEvent, + InteractionEvent, RotationEvent, Seal, + }; + use crate::serder::serialize::{ + serialize_delegated_inception, serialize_delegated_rotation, serialize_inception, + serialize_interaction, serialize_rotation, + }; + use alloc::borrow::Cow; fn non_canonical_at(e: &SerderError) -> Option<(usize, &'static str)> { if let SerderError::NonCanonical { @@ -616,6 +1015,336 @@ mod tests { assert!(matches!(seals[1], ParsedSeal::Last { i: "I" })); } + fn make_prefixer() -> Prefixer<'static> { + MatterBuilder::new() + .with_code(VerKeyCode::Ed25519) + .with_raw(Cow::<[u8]>::Owned(vec![0u8; 32])) + .unwrap() + .build() + .unwrap() + } + + fn make_saider() -> Saider<'static> { + MatterBuilder::new() + .with_code(DigestCode::Blake3_256) + .with_raw(Cow::<[u8]>::Owned(vec![1u8; 32])) + .unwrap() + .build() + .unwrap() + } + + fn make_verfer() -> Verfer<'static> { + MatterBuilder::new() + .with_code(VerKeyCode::Ed25519) + .with_raw(Cow::<[u8]>::Owned(vec![1u8; 32])) + .unwrap() + .build() + .unwrap() + } + + fn probe_icp_bytes() -> Vec { + let event = InceptionEvent::new( + make_prefixer().into(), + Seqner::new(0), + make_saider(), + vec![make_verfer()], + Tholder::Simple(1), + vec![make_saider()], + Tholder::Simple(1), + vec![make_prefixer()], + 1, + vec![ConfigTrait::EstOnly], + vec![Seal::Digest { d: make_saider() }], + ); + serialize_inception(&event).unwrap().as_bytes().to_vec() + } + + fn probe_ixn_bytes() -> Vec { + let event = InteractionEvent::new( + make_prefixer().into(), + Seqner::new(3), + make_saider(), + make_saider(), + vec![], + ); + serialize_interaction(&event).unwrap().as_bytes().to_vec() + } + + fn make_rot() -> RotationEvent { + RotationEvent::new( + make_prefixer().into(), + Seqner::new(2), + make_saider(), + make_saider(), + vec![make_verfer()], + Tholder::Simple(1), + vec![make_saider()], + Tholder::Simple(1), + vec![make_prefixer()], + vec![make_prefixer()], + 1, + vec![], + vec![Seal::Digest { d: make_saider() }], + ) + } + + fn probe_rot_bytes() -> Vec { + serialize_rotation(&make_rot()).unwrap().as_bytes().to_vec() + } + + fn probe_dip_bytes() -> Vec { + let icp = InceptionEvent::new( + make_prefixer().into(), + Seqner::new(0), + make_saider(), + vec![make_verfer()], + Tholder::Simple(1), + vec![make_saider()], + Tholder::Simple(1), + vec![], + 0, + vec![], + vec![], + ); + let delegator: Identifier<'static> = make_prefixer().into(); + let dip = DelegatedInceptionEvent::new(icp, delegator); + serialize_delegated_inception(&dip) + .unwrap() + .as_bytes() + .to_vec() + } + + fn probe_drt_bytes() -> Vec { + let drt = DelegatedRotationEvent::new(make_rot()); + serialize_delegated_rotation(&drt) + .unwrap() + .as_bytes() + .to_vec() + } + + /// Rewrite the six size hex digits (bytes 16..22) to the buffer's actual + /// length so grammar probes are not masked by the version-size check. + fn fix_size(raw: &mut [u8]) { + let size = raw.len(); + let hex = format!("{size:06x}"); + raw[16..22].copy_from_slice(hex.as_bytes()); + } + + #[test] + fn parse_event_reads_writer_output_icp() { + let raw = probe_icp_bytes(); + let ParsedEvent::Inception(p) = parse_event(&raw).unwrap() else { + unreachable!() + }; + assert_eq!(p.sn, "0"); + assert_eq!(p.keys.len(), 1); + assert_eq!(p.config, vec!["EO"]); + assert_eq!(p.anchors.len(), 1); + assert_eq!(p.said.span.len(), 44); + assert_eq!( + &raw[p.said.span.clone()], + p.said.value.as_bytes(), + "span must address the value bytes in raw" + ); + assert_eq!(&raw[p.prefix.span.clone()], p.prefix.value.as_bytes()); + } + + #[test] + fn parse_inception_reads_all_icp_fields() { + let raw = probe_icp_bytes(); + let p = parse_inception(&raw).unwrap(); + assert!(matches!(p.threshold, ParsedTholder::Hex("1"))); + assert!(matches!(p.next_threshold, ParsedTholder::Hex("1"))); + assert_eq!(p.next_keys.len(), 1); + assert!(matches!(p.witness_threshold, ParsedCount::Hex("1"))); + assert_eq!(p.witnesses.len(), 1); + } + + #[test] + fn parse_rotation_reads_all_rot_fields() { + let raw = probe_rot_bytes(); + let p = parse_rotation(&raw).unwrap(); + assert_eq!(p.sn, "2"); + assert_eq!(&raw[p.said.span.clone()], p.said.value.as_bytes()); + assert!(!p.prefix.is_empty()); + assert!(!p.prior.is_empty()); + assert!(matches!(p.threshold, ParsedTholder::Hex("1"))); + assert_eq!(p.keys.len(), 1); + assert!(matches!(p.next_threshold, ParsedTholder::Hex("1"))); + assert_eq!(p.next_keys.len(), 1); + assert!(matches!(p.witness_threshold, ParsedCount::Hex("1"))); + assert_eq!(p.witness_removals.len(), 1); + assert_eq!(p.witness_additions.len(), 1); + assert_eq!(p.anchors.len(), 1); + } + + #[test] + fn parse_interaction_reads_all_ixn_fields() { + let raw = probe_ixn_bytes(); + let p = parse_interaction(&raw).unwrap(); + assert_eq!(p.sn, "3"); + assert_eq!(&raw[p.said.span.clone()], p.said.value.as_bytes()); + assert!(!p.prefix.is_empty()); + assert!(!p.prior.is_empty()); + assert!(p.anchors.is_empty()); + } + + #[test] + fn parse_delegated_inception_reads_icp_and_delegator() { + let raw = probe_dip_bytes(); + let p = parse_delegated_inception(&raw).unwrap(); + assert_eq!(p.icp.sn, "0"); + assert!(!p.delegator.is_empty()); + } + + #[test] + fn parse_delegated_rotation_reads_rot_fields() { + let raw = probe_drt_bytes(); + let p = parse_delegated_rotation(&raw).unwrap(); + assert_eq!(p.sn, "2"); + } + + #[test] + fn parse_event_dispatches_every_ilk_variant() { + match parse_event(&probe_icp_bytes()).unwrap() { + ParsedEvent::Inception(p) => assert_eq!(p.sn, "0"), + other => unreachable!("expected Inception, got {other:?}"), + } + match parse_event(&probe_rot_bytes()).unwrap() { + ParsedEvent::Rotation(p) => assert_eq!(p.sn, "2"), + other => unreachable!("expected Rotation, got {other:?}"), + } + match parse_event(&probe_ixn_bytes()).unwrap() { + ParsedEvent::Interaction(p) => assert_eq!(p.sn, "3"), + other => unreachable!("expected Interaction, got {other:?}"), + } + match parse_event(&probe_dip_bytes()).unwrap() { + ParsedEvent::DelegatedInception(p) => assert_eq!(p.icp.sn, "0"), + other => unreachable!("expected DelegatedInception, got {other:?}"), + } + match parse_event(&probe_drt_bytes()).unwrap() { + ParsedEvent::DelegatedRotation(p) => assert_eq!(p.sn, "2"), + other => unreachable!("expected DelegatedRotation, got {other:?}"), + } + } + + #[test] + fn per_ilk_entry_rejects_wrong_ilk() { + let raw = probe_ixn_bytes(); + assert!(matches!( + parse_rotation(&raw), + Err(SerderError::NonCanonical { + expected: "rot", + .. + }) + )); + } + + #[test] + fn unknown_ilk_is_typed() { + let mut raw = probe_ixn_bytes(); + let pos = raw.windows(5).position(|w| w == b"\"ixn\"").unwrap(); + raw[pos + 1..pos + 4].copy_from_slice(b"xxx"); + assert!(matches!( + parse_event(&raw), + Err(SerderError::UnknownIlk(ref s)) if s == "xxx" + )); + } + + #[test] + fn whitespace_with_consistent_size_is_non_canonical() { + // Insert one space after the first comma AND fix the version size so + // the length check passes — the grammar itself must reject it. + let raw = probe_ixn_bytes(); + let comma = raw.iter().position(|b| *b == b',').unwrap(); + let mut padded = Vec::with_capacity(raw.len() + 1); + padded.extend_from_slice(&raw[..=comma]); + padded.push(b' '); + padded.extend_from_slice(&raw[comma + 1..]); + fix_size(&mut padded); + assert!(matches!( + parse_event(&padded), + Err(SerderError::NonCanonical { .. }) + )); + } + + #[test] + fn duplicate_field_is_non_canonical() { + // Overwrite the `,"i":` key with a second `,"d":` — same length, so + // the version size stays consistent; the grammar must reject it. + let mut raw = probe_ixn_bytes(); + let pos = raw.windows(5).position(|w| w == b",\"i\":").unwrap(); + raw[pos..pos + 5].copy_from_slice(b",\"d\":"); + assert!(matches!( + parse_event(&raw), + Err(SerderError::NonCanonical { .. }) + )); + } + + #[test] + fn reordered_fields_are_non_canonical() { + // Swap the `"s"` and `"p"` key names (same length) in an ixn. + let mut raw = probe_ixn_bytes(); + let s_pos = raw.windows(5).position(|w| w == b",\"s\":").unwrap(); + let p_pos = raw.windows(5).position(|w| w == b",\"p\":").unwrap(); + raw[s_pos + 2] = b'p'; + raw[p_pos + 2] = b's'; + assert!(matches!( + parse_event(&raw), + Err(SerderError::NonCanonical { .. }) + )); + } + + #[test] + fn escape_in_value_is_non_canonical() { + // Replace sn value "3" with an escaped form and fix the size field. + let raw = probe_ixn_bytes(); + let pos = raw.windows(8).position(|w| w == b",\"s\":\"3\"").unwrap(); + let mut mutated = Vec::with_capacity(raw.len() + 5); + mutated.extend_from_slice(&raw[..pos]); + mutated.extend_from_slice(b",\"s\":\"\\u0033\""); + mutated.extend_from_slice(&raw[pos + 8..]); + fix_size(&mut mutated); + assert!(matches!( + parse_event(&mutated), + Err(SerderError::NonCanonical { .. }) + )); + } + + #[test] + fn trailing_bytes_are_non_canonical() { + let mut raw = probe_ixn_bytes(); + raw.push(b'X'); + fix_size(&mut raw); + assert!(matches!( + parse_event(&raw), + Err(SerderError::NonCanonical { .. }) + )); + } + + #[test] + fn length_lie_is_still_invalid_version_string() { + // Without fixing the size field, a padded input fails the size check + // first — preserving the #139 defence. + let mut raw = probe_ixn_bytes(); + raw.push(b'X'); + assert!(matches!( + parse_event(&raw), + Err(SerderError::InvalidVersionString(_)) + )); + } + + #[test] + fn every_strict_prefix_is_rejected_without_panicking() { + let raw = probe_icp_bytes(); + for cut in 0..raw.len() { + assert!( + parse_event(&raw[..cut]).is_err(), + "truncation at {cut} must be rejected" + ); + } + } + mod properties { use super::*; use proptest::prelude::*; @@ -637,6 +1366,7 @@ mod tests { let _ = count(&mut Scanner::new(&input)); let _ = seal(&mut Scanner::new(&input)); let _ = seal_array(&mut Scanner::new(&input)); + let _ = parse_event(&input); } /// Load-bearing invariant: an accepted string's span addresses From 9cd8b877c223fdf1b9904775bb2dbde54921a107 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Fri, 10 Jul 2026 22:23:25 +0200 Subject: [PATCH 07/21] fix(serder): version-string parse must never panic on non-ASCII bytes (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VersionString::parse sliced the input &str at fixed byte offsets; a multi-byte UTF-8 char straddling a field boundary (offsets 4, 5, 6, 10, or 16) is not a char boundary, so the slice panicked — a DoS on untrusted input. The panic predates #142: it is reachable on main via validate_version_string in deserialize.rs, which feeds serde_json- unescaped arbitrary UTF-8 into VersionString::parse. Closed at the root: every fixed-offset slice now goes through checked str::get and returns InvalidVersionString. Defense in depth at the strict-parser seam: canonical.rs::head() now rejects any non-ASCII byte in the 17-byte version window with a NonCanonical error reporting the precise violating byte, before the window ever reaches VersionString::parse. Also: document require_ilk's error-offset convention, and add five grammar rejection probes (wrong first byte, oversized ilk, delegator field on icp, missing delegator on dip, corrupt version terminator seam). Co-Authored-By: Claude Fable 5 --- cesr/src/serder/deserialize/canonical.rs | 80 +++++++++++++++++++++++- cesr/src/serder/version.rs | 54 +++++++++++++--- 2 files changed, 124 insertions(+), 10 deletions(-) diff --git a/cesr/src/serder/deserialize/canonical.rs b/cesr/src/serder/deserialize/canonical.rs index aa75599..ff42f09 100644 --- a/cesr/src/serder/deserialize/canonical.rs +++ b/cesr/src/serder/deserialize/canonical.rs @@ -471,7 +471,15 @@ fn head(raw: &[u8]) -> Result<(Scanner<'_>, Spanned<'_>), SerderError> { let vs_bytes = raw .get(vs_start..vs_end) .ok_or_else(|| sc.err("17-byte version string"))?; - let vs_str = str::from_utf8(vs_bytes).map_err(|_| sc.err("ASCII version string"))?; + if let Some(rel) = vs_bytes.iter().position(|b| !b.is_ascii()) { + let offset = vs_start + .checked_add(rel) + .ok_or(SerderError::InvalidEventLayout("version span overflow"))?; + return Err(sc.err_at(offset, "ASCII version string")); + } + // Defensively unreachable: all bytes verified ASCII above. + let vs_str = + str::from_utf8(vs_bytes).map_err(|_| sc.err_at(vs_start, "ASCII version string"))?; let vs = VersionString::parse(vs_str)?; if vs.kind != SerKind::Json { return Err(SerderError::InvalidVersionString(format!( @@ -613,6 +621,9 @@ fn ixn_body(mut sc: Scanner<'_>) -> Result, SerderError> { }) } +/// On mismatch the error's offset addresses the ilk value's first byte +/// (inside the quotes) and `expected` carries the bare ilk name — the same +/// start-offset convention as [`Scanner::expect`]. fn require_ilk( sc: &Scanner<'_>, ilk: &Spanned<'_>, @@ -1345,6 +1356,73 @@ mod tests { } } + #[test] + fn multibyte_utf8_in_version_window_is_rejected_not_panicking() { + // 23 bytes: char 'é' straddles the proto/major boundary at offset 4 + // of the version window — previously panicked inside + // VersionString::parse via non-char-boundary &str slicing. + assert!(parse_event(b"{\"v\":\"KER\xC3\xA9AJSONAAAAAA_").is_err()); + } + + #[test] + fn wrong_first_byte_is_non_canonical() { + assert!(matches!( + parse_event(b"[\"v\":\"KERI10JSON000017_"), + Err(SerderError::NonCanonical { offset: 0, .. }) + )); + } + + #[test] + fn oversized_ilk_is_rejected() { + let raw = probe_ixn_bytes(); + let pos = raw.windows(5).position(|w| w == b"\"ixn\"").unwrap(); + let mut mutated = Vec::with_capacity(raw.len() + 1); + mutated.extend_from_slice(&raw[..pos + 4]); + mutated.push(b'X'); + mutated.extend_from_slice(&raw[pos + 4..]); + fix_size(&mut mutated); + assert!(matches!( + parse_event(&mutated), + Err(SerderError::UnknownIlk(ref s)) if s == "ixnX" + )); + assert!(matches!( + parse_interaction(&mutated), + Err(SerderError::NonCanonical { .. }) + )); + } + + #[test] + fn delegator_field_on_icp_is_rejected() { + // icp grammar ends at the anchors; a trailing "di" is non-canonical. + let mut raw = probe_dip_bytes(); + let pos = raw.windows(5).position(|w| w == b"\"dip\"").unwrap(); + raw[pos + 1..pos + 4].copy_from_slice(b"icp"); + assert!(matches!( + parse_event(&raw), + Err(SerderError::NonCanonical { .. }) + )); + } + + #[test] + fn missing_delegator_on_dip_is_rejected() { + // ilk says dip but the body is an icp body — fails at `,"di":`. + let mut raw = probe_icp_bytes(); + let pos = raw.windows(5).position(|w| w == b"\"icp\"").unwrap(); + raw[pos + 1..pos + 4].copy_from_slice(b"dip"); + assert!(matches!( + parse_event(&raw), + Err(SerderError::NonCanonical { .. }) + )); + } + + #[test] + fn corrupt_version_terminator_seam_is_rejected() { + let mut raw = probe_ixn_bytes(); + // byte 23 is the closing quote of the version string value + raw[23] = b'X'; + assert!(parse_event(&raw).is_err()); + } + mod properties { use super::*; use proptest::prelude::*; diff --git a/cesr/src/serder/version.rs b/cesr/src/serder/version.rs index 7976ac9..6ecfa1d 100644 --- a/cesr/src/serder/version.rs +++ b/cesr/src/serder/version.rs @@ -18,6 +18,7 @@ use crate::serder::error::SerderError; reason = "alloc prelude items; subset used per cfg/feature combination" )] use alloc::{format, string::String}; +use core::ops::Range; /// Total length of a V1 version string in bytes. pub const VERSION_STRING_LEN: usize = 17; @@ -204,7 +205,8 @@ impl VersionString { /// # Errors /// /// Returns [`SerderError::InvalidVersionString`] if the input is too - /// short, contains unrecognized fields, or is missing the terminator. + /// short, contains unrecognized or non-ASCII fields, or is missing the + /// terminator. pub fn parse(input: &str) -> Result { if input.len() < VERSION_STRING_LEN { return Err(SerderError::InvalidVersionString(format!( @@ -213,14 +215,23 @@ impl VersionString { ))); } - let vs = &input[..VERSION_STRING_LEN]; - - let proto_str = &vs[..PROTO_LEN]; + // Every field lives at a fixed byte offset; a multi-byte UTF-8 char + // straddling a field boundary makes that offset a non-char-boundary, + // so checked `get` (never panicking `[a..b]`) is load-bearing here. + let segment = |range: Range| { + input.get(range).ok_or_else(|| { + SerderError::InvalidVersionString( + "non-ASCII or malformed version string segment".into(), + ) + }) + }; + + let proto_str = segment(0..PROTO_LEN)?; let proto = Protocol::from_repr(proto_str)?; let version_start = PROTO_LEN; - let major_ch = &vs[version_start..=version_start]; - let minor_ch = &vs[version_start + 1..version_start + VERSION_LEN]; + let major_ch = segment(version_start..version_start + 1)?; + let minor_ch = segment(version_start + 1..version_start + VERSION_LEN)?; let major = u8::from_str_radix(major_ch, 16).map_err(|_| { SerderError::InvalidVersionString(format!( @@ -235,16 +246,16 @@ impl VersionString { })?; let kind_start = PROTO_LEN + VERSION_LEN; - let kind_str = &vs[kind_start..kind_start + KIND_LEN]; + let kind_str = segment(kind_start..kind_start + KIND_LEN)?; let kind = SerKind::from_repr(kind_str)?; let size_start = kind_start + KIND_LEN; - let size_str = &vs[size_start..size_start + SIZE_LEN]; + let size_str = segment(size_start..size_start + SIZE_LEN)?; let size = u32::from_str_radix(size_str, 16).map_err(|_| { SerderError::InvalidVersionString(format!("invalid size hex: {size_str}")) })?; - let terminator = &vs[VERSION_STRING_LEN - 1..VERSION_STRING_LEN]; + let terminator = segment(VERSION_STRING_LEN - 1..VERSION_STRING_LEN)?; if terminator != "_" { return Err(SerderError::InvalidVersionString(format!( "missing terminator '_', found '{terminator}'" @@ -375,6 +386,31 @@ mod tests { assert!(err_msg.contains("unknown protocol")); } + #[test] + fn parse_multibyte_char_straddling_proto_boundary_is_error_not_panic() { + // 'é' occupies bytes 3..5, so byte offset 4 (the proto/major + // boundary) is not a char boundary — previously panicked in the + // fixed-offset &str slicing. + let input = "KER\u{e9}AJSONAAAAAA_"; + assert_eq!(input.len(), VERSION_STRING_LEN); + assert!(matches!( + VersionString::parse(input), + Err(SerderError::InvalidVersionString(_)) + )); + } + + #[test] + fn parse_multibyte_char_straddling_terminator_boundary_is_error_not_panic() { + // 'é' occupies bytes 15..17, so byte offset 16 (the size/terminator + // boundary) is not a char boundary. + let input = "KERI10JSONAAAAA\u{e9}"; + assert_eq!(input.len(), VERSION_STRING_LEN); + assert!(matches!( + VersionString::parse(input), + Err(SerderError::InvalidVersionString(_)) + )); + } + #[test] fn parse_unknown_kind() { let result = VersionString::parse("KERI10YAML000000_"); From 12f3547759ae49ccc0be1149cbdbae2053300538 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Fri, 10 Jul 2026 22:31:56 +0200 Subject: [PATCH 08/21] =?UTF-8?q?feat(serder):=20offset-based=20SAID=20ver?= =?UTF-8?q?ification=20=E2=80=94=20copy-once,=20fill=20spans,=20hash=20(#1?= =?UTF-8?q?42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- cesr/src/serder/said.rs | 153 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 2 deletions(-) diff --git a/cesr/src/serder/said.rs b/cesr/src/serder/said.rs index af9165a..bce2836 100644 --- a/cesr/src/serder/said.rs +++ b/cesr/src/serder/said.rs @@ -15,7 +15,9 @@ use crate::crypto::digest::digest; unused_imports, reason = "alloc prelude items; subset used per cfg/feature combination" )] -use alloc::{borrow::ToOwned, string::String, string::ToString}; +use alloc::{borrow::ToOwned, string::String, string::ToString, vec::Vec}; +#[cfg(test)] +use core::ops::Range; use crate::serder::error::SerderError; use crate::serder::primitives::to_qb64_string; @@ -26,6 +28,10 @@ use crate::serder::primitives::to_qb64_string; /// unambiguously distinguishable from a real SAID. pub const DUMMY_CHAR: char = '#'; +/// Byte form of [`DUMMY_CHAR`] for in-place span filling. +#[cfg(test)] +pub(crate) const DUMMY_BYTE: u8 = b'#'; + /// Generate a placeholder string of the correct qb64 length for `code`. /// /// For `Blake3_256` the result is 44 `#` characters. @@ -101,10 +107,72 @@ pub fn verify_said(raw: &[u8], code: DigestCode) -> Result { Ok(original_said == computed_qb64) } +// Test-gated until the deserialize entry points adopt span-based SAID +// verification (#142 rewire); the gate is removed when the first production +// caller lands. +#[cfg(test)] +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +/// Verify a SAID by span: copy `raw` once into a scratch buffer, overwrite +/// the SAID value span (and the prefix span for double-SAID events) with +/// [`DUMMY_BYTE`], hash, and compare against `said_value`. +/// +/// Spans come from the canonical parser and must address the qb64 value +/// bytes exactly (quotes excluded). This replaces the historical +/// parse-mutate-re-render verification with one allocation and one hash. +/// +/// # Errors +/// +/// Returns [`SerderError::SaidMismatch`] if the computed digest differs, +/// [`SerderError::InvalidEventLayout`] if a span is out of bounds, or +/// [`SerderError::DigestError`] on hash failure. +pub(crate) fn verify_said_spans( + raw: &[u8], + said_value: &str, + said_span: &Range, + prefix_span: Option<&Range>, + code: DigestCode, +) -> Result<(), SerderError> { + let mut scratch = raw.to_vec(); + fill_span(&mut scratch, said_span)?; + if let Some(span) = prefix_span { + fill_span(&mut scratch, span)?; + } + let computed = compute_digest(&scratch, code)?; + let computed_qb64 = to_qb64_string(&computed); + if said_value == computed_qb64 { + Ok(()) + } else { + Err(SerderError::SaidMismatch { + expected: said_value.to_owned(), + computed: computed_qb64, + }) + } +} + +#[cfg(test)] +fn fill_span(scratch: &mut [u8], span: &Range) -> Result<(), SerderError> { + scratch + .get_mut(span.clone()) + .ok_or(SerderError::InvalidEventLayout("SAID span out of bounds"))? + .fill(DUMMY_BYTE); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; - use crate::core::matter::code::DigestCode; + use crate::core::matter::builder::MatterBuilder; + use crate::core::matter::code::{DigestCode, VerKeyCode}; + use crate::core::primitives::Seqner; + use crate::keri::InteractionEvent; + use crate::serder::builder::icp::InceptionBuilder; + use crate::serder::serialize::serialize_interaction; + use alloc::borrow::Cow; + use alloc::vec; + use alloc::vec::Vec; #[test] fn placeholder_blake3_256_is_44_chars() { @@ -146,4 +214,85 @@ mod tests { let b = compute_digest(b"bravo", DigestCode::Blake3_256).expect("digest bravo"); assert_ne!(to_qb64_string(&a), to_qb64_string(&b)); } + + fn probe_ixn_raw() -> (Vec, String) { + let prefixer = MatterBuilder::new() + .with_code(VerKeyCode::Ed25519) + .with_raw(Cow::<[u8]>::Owned(vec![0u8; 32])) + .unwrap() + .build() + .unwrap(); + let saider_fixture = compute_digest(b"seed", DigestCode::Blake3_256).unwrap(); + let event = InteractionEvent::new( + prefixer.into(), + Seqner::new(1), + saider_fixture.clone(), + saider_fixture, + vec![], + ); + let ser = serialize_interaction(&event).unwrap(); + let said = to_qb64_string(ser.said()); + (ser.as_bytes().to_vec(), said) + } + + #[test] + fn verify_said_spans_accepts_writer_output() { + let (raw, said) = probe_ixn_raw(); + let start = raw + .windows(6) + .position(|w| w == b"\"d\":\"E") + .expect("d field present") + + 5; + let span = start..start + 44; + assert_eq!(&raw[span.clone()], said.as_bytes()); + verify_said_spans(&raw, &said, &span, None, DigestCode::Blake3_256) + .expect("writer output must verify"); + } + + #[test] + fn verify_said_spans_rejects_tamper() { + let (mut raw, said) = probe_ixn_raw(); + let start = raw.windows(6).position(|w| w == b"\"d\":\"E").unwrap() + 5; + let span = start..start + 44; + let s_pos = raw.windows(8).position(|w| w == b",\"s\":\"1\"").unwrap(); + raw[s_pos + 6] = b'2'; + assert!(matches!( + verify_said_spans(&raw, &said, &span, None, DigestCode::Blake3_256), + Err(SerderError::SaidMismatch { .. }) + )); + } + + #[test] + fn verify_said_spans_rejects_out_of_bounds_span() { + let (raw, said) = probe_ixn_raw(); + let bogus = raw.len()..raw.len() + 44; + assert!(matches!( + verify_said_spans(&raw, &said, &bogus, None, DigestCode::Blake3_256), + Err(SerderError::InvalidEventLayout(_)) + )); + } + + #[test] + fn verify_said_spans_double_said_matches_reference() { + // For an icp whose d == i (self-addressing), filling BOTH spans must + // reproduce the SAID the writer computed (the writer patches both + // slots from one digest over a double-placeholder render). + let verfer = MatterBuilder::new() + .with_code(VerKeyCode::Ed25519) + .with_raw(Cow::<[u8]>::Owned(vec![7u8; 32])) + .unwrap() + .build() + .unwrap(); + let icp = InceptionBuilder::new().keys(vec![verfer]).build().unwrap(); + let raw = icp.as_bytes().to_vec(); + let said = to_qb64_string(icp.said()); + let d_start = raw.windows(5).position(|w| w == b"\"d\":\"").unwrap() + 5; + let i_start = raw.windows(5).position(|w| w == b"\"i\":\"").unwrap() + 5; + let d_span = d_start..d_start + 44; + let i_span = i_start..i_start + 44; + assert_eq!(&raw[d_span.clone()], said.as_bytes()); + assert_eq!(&raw[i_span.clone()], said.as_bytes()); + verify_said_spans(&raw, &said, &d_span, Some(&i_span), DigestCode::Blake3_256) + .expect("double-SAID writer output must verify by span"); + } } From 8991b1ee466fff3c7796bbeb3717bbce990f90c0 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Fri, 10 Jul 2026 22:54:15 +0200 Subject: [PATCH 09/21] feat(serder)!: rewire deserializers onto the strict canonical parser (#142) BREAKING: non-canonical input (whitespace, reordered/duplicate fields, escapes, trailing bytes) now fails with SerderError::NonCanonical; per-ilk deserializers require their exact ilk (deserialize_rotation no longer accepts drt bytes, deserialize_inception no longer accepts dip bytes). The tolerant serde_json path survives as the #[cfg(test)] differential oracle (deserialize::reference). Co-Authored-By: Claude Fable 5 --- cesr/src/serder/deserialize.rs | 960 +++++++++-------------- cesr/src/serder/deserialize/canonical.rs | 2 +- cesr/src/serder/deserialize/reference.rs | 856 ++++++++++++++++++++ cesr/src/serder/said.rs | 71 +- cesr/src/serder/serialize/direct.rs | 4 +- 5 files changed, 1269 insertions(+), 624 deletions(-) create mode 100644 cesr/src/serder/deserialize/reference.rs diff --git a/cesr/src/serder/deserialize.rs b/cesr/src/serder/deserialize.rs index 0b67fb0..77ad76b 100644 --- a/cesr/src/serder/deserialize.rs +++ b/cesr/src/serder/deserialize.rs @@ -1,15 +1,17 @@ -//! KERI event deserialization from canonical JSON with SAID verification. +//! KERI event deserialization from strict canonical JSON with SAID +//! verification. //! -//! Parses JSON-encoded KERI events produced by the serialization module, -//! reconstructing [`keri_core`] domain types. Every deserialized event is -//! verified against its SAID before being returned. +//! Parses the five fixed canonical event grammars via the single-pass +//! [`canonical`] parser, reconstructing [`crate::keri`] domain types. Every +//! deserialized event's SAID is verified in place over the raw bytes (span +//! fill + hash) before being returned. use crate::core::matter::builder::MatterBuilder; use crate::core::matter::code::{DigestCode, MatterCode, VerKeyCode}; use crate::core::matter::error::{MatterBuildError, ValidationError}; use crate::core::primitives::{Diger, Prefixer, Saider, Seqner, Tholder, Verfer}; use crate::keri::{ - ConfigTrait, DelegatedInceptionEvent, DelegatedRotationEvent, Identifier, Ilk, InceptionEvent, + ConfigTrait, DelegatedInceptionEvent, DelegatedRotationEvent, Identifier, InceptionEvent, InteractionEvent, KeriEvent, RotationEvent, Seal, }; #[cfg(feature = "alloc")] @@ -18,343 +20,341 @@ use crate::keri::{ reason = "alloc prelude items; subset used per cfg/feature combination" )] use alloc::{borrow::ToOwned, format, string::String, string::ToString, vec, vec::Vec}; -use serde_json::Value; +use self::canonical::{ + ParsedCount, ParsedDip, ParsedEvent, ParsedIcp, ParsedIxn, ParsedRot, ParsedSeal, + ParsedTholder, Spanned, +}; use crate::serder::error::SerderError; -use crate::serder::primitives::to_qb64_string; -use crate::serder::said::{compute_digest, said_placeholder}; -use crate::serder::version::{SerKind, VERSION_STRING_LEN, VersionString}; +use crate::serder::said::verify_said_spans; -// Test-gated until the deserialize entry points adopt the strict parser -// (#142 rewire); the gate is removed when the first production caller lands. -#[cfg(test)] pub(crate) mod canonical; +#[cfg(test)] +pub(crate) mod reference; + // --------------------------------------------------------------------------- // Public deserialization entry points // --------------------------------------------------------------------------- -/// Deserialize any KERI event from canonical JSON bytes. +/// Deserialize any KERI event from strict canonical JSON bytes. /// -/// Parses the `t` (ilk) field to dispatch to the appropriate event-specific -/// deserializer, then verifies the SAID before returning. +/// Dispatches on the wire `t` (ilk) field, then verifies the SAID in place +/// over the raw bytes before building the domain event. /// /// # Errors /// -/// Returns [`SerderError`] if JSON parsing fails, required fields are missing -/// or invalid, or the SAID does not verify. +/// Returns [`SerderError::NonCanonical`] if the input deviates from the +/// strict canonical grammar (whitespace, reordered or duplicate fields, +/// escapes, trailing bytes), [`SerderError::InvalidVersionString`] if the +/// version string is malformed or inconsistent with the input length, +/// [`SerderError::UnknownIlk`] if `t` is not a KEL ilk, or another +/// [`SerderError`] if a field is invalid or the SAID does not verify. pub fn deserialize_event(raw: &[u8]) -> Result { - validate_version_string(raw)?; - let val: Value = serde_json::from_slice(raw)?; - let ilk_str = get_str(&val, "t")?; - let ilk = Ilk::from_code(ilk_str).map_err(|_| SerderError::UnknownIlk(ilk_str.to_owned()))?; - - match ilk { - Ilk::Icp => Ok(KeriEvent::Inception(deserialize_inception(raw)?)), - Ilk::Rot => Ok(KeriEvent::Rotation(deserialize_rotation(raw)?)), - Ilk::Ixn => Ok(KeriEvent::Interaction(deserialize_interaction(raw)?)), - Ilk::Dip => Ok(KeriEvent::DelegatedInception( - deserialize_delegated_inception(raw)?, - )), - Ilk::Drt => Ok(KeriEvent::DelegatedRotation( - deserialize_delegated_rotation(raw)?, - )), - _ => Err(SerderError::UnknownIlk(ilk_str.to_owned())), + match canonical::parse_event(raw)? { + ParsedEvent::Inception(p) => { + verify_inception_said(raw, &p)?; + Ok(KeriEvent::Inception(build_inception(&p)?)) + } + ParsedEvent::Rotation(p) => { + verify_single_said(raw, &p.said)?; + Ok(KeriEvent::Rotation(build_rotation(&p)?)) + } + ParsedEvent::Interaction(p) => { + verify_single_said(raw, &p.said)?; + Ok(KeriEvent::Interaction(build_interaction(&p)?)) + } + ParsedEvent::DelegatedInception(p) => { + verify_inception_said(raw, &p.icp)?; + Ok(KeriEvent::DelegatedInception(build_delegated_inception( + &p, + )?)) + } + ParsedEvent::DelegatedRotation(p) => { + verify_single_said(raw, &p.said)?; + Ok(KeriEvent::DelegatedRotation(DelegatedRotationEvent::new( + build_rotation(&p)?, + ))) + } } } -/// Deserialize an inception event from canonical JSON bytes. +/// Deserialize an inception event from strict canonical JSON bytes. /// -/// Verifies the double-SAID property (both `d` and `i` are replaced with -/// placeholders during verification). +/// Verifies the double-SAID property when `d == i`: both spans are filled +/// with placeholders in place over the raw bytes before hashing. /// /// # Errors /// -/// Returns [`SerderError`] if JSON parsing fails, the version string is -/// malformed or inconsistent with the input length, required fields are -/// missing or invalid, or the SAID does not verify. +/// Returns [`SerderError::NonCanonical`] if the input deviates from the +/// strict canonical grammar or its ilk is not `icp`, +/// [`SerderError::InvalidVersionString`] if the version string is malformed +/// or inconsistent with the input length, or another [`SerderError`] if a +/// field is invalid or the SAID does not verify. pub fn deserialize_inception(raw: &[u8]) -> Result { - validate_version_string(raw)?; - let val: Value = serde_json::from_slice(raw)?; - let digest_code = infer_digest_code(get_str(&val, "d")?)?; - let d_str = get_str(&val, "d")?; - let i_str = get_str(&val, "i")?; - - if d_str == i_str { - verify_said_double(raw, digest_code)?; - } else { - verify_said_single(raw, digest_code)?; - } - - let said = parse_qb64_diger(get_str(&val, "d")?, "d")?; - let prefix = parse_qb64_identifier(get_str(&val, "i")?, "i")?; - let sn = parse_sn(get_str(&val, "s")?)?; - let threshold = tholder_from_json(get_field(&val, "kt")?)?; - let keys = parse_qb64_verfer_array(get_field(&val, "k")?)?; - let next_threshold = tholder_from_json(get_field(&val, "nt")?)?; - let next_keys = parse_qb64_diger_array(get_field(&val, "n")?)?; - let witness_threshold = parse_witness_threshold(get_field(&val, "bt")?)?; - let witnesses = parse_qb64_prefixer_array(get_field(&val, "b")?)?; - let config = parse_config_array(get_field(&val, "c")?)?; - let anchors = parse_seal_array(get_field(&val, "a")?)?; - - Ok(InceptionEvent::new( - prefix, - Seqner::new(sn), - said, - keys, - threshold, - next_keys, - next_threshold, - witnesses, - witness_threshold, - config, - anchors, - )) + let parsed = canonical::parse_inception(raw)?; + verify_inception_said(raw, &parsed)?; + build_inception(&parsed) } -/// Deserialize a rotation event from canonical JSON bytes. +/// Deserialize a rotation event from strict canonical JSON bytes. +/// +/// The SAID is verified in place over the raw bytes. /// /// # Errors /// -/// Returns [`SerderError`] if JSON parsing fails, the version string is -/// malformed or inconsistent with the input length, required fields are -/// missing or invalid, or the SAID does not verify. +/// Returns [`SerderError::NonCanonical`] if the input deviates from the +/// strict canonical grammar or its ilk is not `rot`, +/// [`SerderError::InvalidVersionString`] if the version string is malformed +/// or inconsistent with the input length, or another [`SerderError`] if a +/// field is invalid or the SAID does not verify. pub fn deserialize_rotation(raw: &[u8]) -> Result { - validate_version_string(raw)?; - let val: Value = serde_json::from_slice(raw)?; - let digest_code = infer_digest_code(get_str(&val, "d")?)?; - - verify_said_single(raw, digest_code)?; - - let said = parse_qb64_diger(get_str(&val, "d")?, "d")?; - let prefix = parse_qb64_identifier(get_str(&val, "i")?, "i")?; - let sn = parse_sn(get_str(&val, "s")?)?; - let prior_event_said = parse_qb64_diger(get_str(&val, "p")?, "p")?; - let threshold = tholder_from_json(get_field(&val, "kt")?)?; - let keys = parse_qb64_verfer_array(get_field(&val, "k")?)?; - let next_threshold = tholder_from_json(get_field(&val, "nt")?)?; - let next_keys = parse_qb64_diger_array(get_field(&val, "n")?)?; - let witness_threshold = parse_witness_threshold(get_field(&val, "bt")?)?; - let witness_removals = parse_qb64_prefixer_array(get_field(&val, "br")?)?; - let witness_additions = parse_qb64_prefixer_array(get_field(&val, "ba")?)?; - let config = match val.get("c") { - Some(c_val) => parse_config_array(c_val)?, - None => vec![], - }; - let anchors = parse_seal_array(get_field(&val, "a")?)?; - - Ok(RotationEvent::new( - prefix, - Seqner::new(sn), - said, - prior_event_said, - keys, - threshold, - next_keys, - next_threshold, - witness_additions, - witness_removals, - witness_threshold, - config, - anchors, - )) + let parsed = canonical::parse_rotation(raw)?; + verify_single_said(raw, &parsed.said)?; + build_rotation(&parsed) } -/// Deserialize an interaction event from canonical JSON bytes. +/// Deserialize an interaction event from strict canonical JSON bytes. +/// +/// The SAID is verified in place over the raw bytes. /// /// # Errors /// -/// Returns [`SerderError`] if JSON parsing fails, the version string is -/// malformed or inconsistent with the input length, required fields are -/// missing or invalid, or the SAID does not verify. +/// Returns [`SerderError::NonCanonical`] if the input deviates from the +/// strict canonical grammar or its ilk is not `ixn`, +/// [`SerderError::InvalidVersionString`] if the version string is malformed +/// or inconsistent with the input length, or another [`SerderError`] if a +/// field is invalid or the SAID does not verify. pub fn deserialize_interaction(raw: &[u8]) -> Result { - validate_version_string(raw)?; - let val: Value = serde_json::from_slice(raw)?; - let digest_code = infer_digest_code(get_str(&val, "d")?)?; - - verify_said_single(raw, digest_code)?; - - let said = parse_qb64_diger(get_str(&val, "d")?, "d")?; - let prefix = parse_qb64_identifier(get_str(&val, "i")?, "i")?; - let sn = parse_sn(get_str(&val, "s")?)?; - let prior_event_said = parse_qb64_diger(get_str(&val, "p")?, "p")?; - let anchors = parse_seal_array(get_field(&val, "a")?)?; - - Ok(InteractionEvent::new( - prefix, - Seqner::new(sn), - said, - prior_event_said, - anchors, - )) + let parsed = canonical::parse_interaction(raw)?; + verify_single_said(raw, &parsed.said)?; + build_interaction(&parsed) } -/// Deserialize a delegated inception event from canonical JSON bytes. +/// Deserialize a delegated inception event from strict canonical JSON bytes. /// -/// Verifies the double-SAID property (both `d` and `i` are replaced with -/// placeholders during verification). +/// Verifies the double-SAID property when `d == i`: both spans are filled +/// with placeholders in place over the raw bytes before hashing. /// /// # Errors /// -/// Returns [`SerderError`] if JSON parsing fails, the version string is -/// malformed or inconsistent with the input length, required fields are -/// missing or invalid, or the SAID does not verify. +/// Returns [`SerderError::NonCanonical`] if the input deviates from the +/// strict canonical grammar or its ilk is not `dip`, +/// [`SerderError::InvalidVersionString`] if the version string is malformed +/// or inconsistent with the input length, or another [`SerderError`] if a +/// field is invalid or the SAID does not verify. pub fn deserialize_delegated_inception(raw: &[u8]) -> Result { - validate_version_string(raw)?; - let val: Value = serde_json::from_slice(raw)?; - let digest_code = infer_digest_code(get_str(&val, "d")?)?; - let d_str = get_str(&val, "d")?; - let i_str = get_str(&val, "i")?; - - if d_str == i_str { - verify_said_double(raw, digest_code)?; - } else { - verify_said_single(raw, digest_code)?; - } - - let said = parse_qb64_diger(get_str(&val, "d")?, "d")?; - let prefix = parse_qb64_identifier(get_str(&val, "i")?, "i")?; - let sn = parse_sn(get_str(&val, "s")?)?; - let threshold = tholder_from_json(get_field(&val, "kt")?)?; - let keys = parse_qb64_verfer_array(get_field(&val, "k")?)?; - let next_threshold = tholder_from_json(get_field(&val, "nt")?)?; - let next_keys = parse_qb64_diger_array(get_field(&val, "n")?)?; - let witness_threshold = parse_witness_threshold(get_field(&val, "bt")?)?; - let witnesses = parse_qb64_prefixer_array(get_field(&val, "b")?)?; - let config = parse_config_array(get_field(&val, "c")?)?; - let anchors = parse_seal_array(get_field(&val, "a")?)?; - let delegator = parse_qb64_identifier(get_str(&val, "di")?, "di")?; - - Ok(DelegatedInceptionEvent::new( - InceptionEvent::new( - prefix, - Seqner::new(sn), - said, - keys, - threshold, - next_keys, - next_threshold, - witnesses, - witness_threshold, - config, - anchors, - ), - delegator, - )) + let parsed = canonical::parse_delegated_inception(raw)?; + verify_inception_said(raw, &parsed.icp)?; + build_delegated_inception(&parsed) } -/// Deserialize a delegated rotation event from canonical JSON bytes. +/// Deserialize a delegated rotation event from strict canonical JSON bytes. +/// +/// The SAID is verified in place over the raw bytes. /// /// # Errors /// -/// Returns [`SerderError`] if JSON parsing fails, the version string is -/// malformed or inconsistent with the input length, required fields are -/// missing or invalid, or the SAID does not verify. +/// Returns [`SerderError::NonCanonical`] if the input deviates from the +/// strict canonical grammar or its ilk is not `drt`, +/// [`SerderError::InvalidVersionString`] if the version string is malformed +/// or inconsistent with the input length, or another [`SerderError`] if a +/// field is invalid or the SAID does not verify. pub fn deserialize_delegated_rotation(raw: &[u8]) -> Result { - let rotation = deserialize_rotation(raw)?; - Ok(DelegatedRotationEvent::new(rotation)) + let parsed = canonical::parse_delegated_rotation(raw)?; + verify_single_said(raw, &parsed.said)?; + Ok(DelegatedRotationEvent::new(build_rotation(&parsed)?)) } // --------------------------------------------------------------------------- -// Version string validation +// SAID verification over parsed spans // --------------------------------------------------------------------------- -fn validate_version_string(raw: &[u8]) -> Result<(), SerderError> { - let val: Value = serde_json::from_slice(raw)?; - let vs_str = val - .get("v") - .and_then(Value::as_str) - .ok_or(SerderError::MissingField("v"))?; - - if vs_str.len() < VERSION_STRING_LEN { - return Err(SerderError::InvalidVersionString(format!( - "version string too short: {}", - vs_str.len() - ))); - } - let vs = VersionString::parse(vs_str)?; - if vs.kind != SerKind::Json { - return Err(SerderError::InvalidVersionString(format!( - "expected JSON, got {}", - vs.kind.as_str() - ))); - } - let expected_size = - usize::try_from(vs.size).map_err(|e| SerderError::InvalidVersionString(e.to_string()))?; - if expected_size != raw.len() { - return Err(SerderError::InvalidVersionString(format!( - "version string size {} does not match actual size {}", - expected_size, - raw.len() - ))); - } - Ok(()) +fn verify_single_said(raw: &[u8], said: &Spanned<'_>) -> Result<(), SerderError> { + let code = infer_digest_code(said.value)?; + verify_said_spans(raw, said, None, code) +} + +/// Double-SAID fill (both `d` and `i`) applies only when the prefix is +/// self-addressing, i.e. `d == i` — matching the write path and keripy. +fn verify_inception_said(raw: &[u8], parsed: &ParsedIcp<'_>) -> Result<(), SerderError> { + let code = infer_digest_code(parsed.said.value)?; + let prefix = (parsed.said.value == parsed.prefix.value).then_some(&parsed.prefix); + verify_said_spans(raw, &parsed.said, prefix, code) } // --------------------------------------------------------------------------- -// SAID verification helpers +// Domain-event builders over parsed views // --------------------------------------------------------------------------- -/// Verify a single-SAID event (rot, ixn, drt): only `d` is replaced with a -/// placeholder before computing the digest. -fn verify_said_single(raw: &[u8], code: DigestCode) -> Result<(), SerderError> { - let mut value: Value = serde_json::from_slice(raw)?; - let obj = value - .as_object_mut() - .ok_or(SerderError::MissingField("d"))?; - - let original_said = obj - .get("d") - .and_then(Value::as_str) - .ok_or(SerderError::MissingField("d"))? - .to_owned(); - - let placeholder = said_placeholder(code)?; - obj.insert("d".to_owned(), Value::String(placeholder)); - - let reser = serde_json::to_string(&value)?; - let computed = compute_digest(reser.as_bytes(), code)?; - let computed_qb64 = to_qb64_string(&computed); - - if original_said != computed_qb64 { - return Err(SerderError::SaidMismatch { - expected: original_said, - computed: computed_qb64, - }); +fn build_inception(p: &ParsedIcp<'_>) -> Result { + Ok(InceptionEvent::new( + parse_qb64_identifier(p.prefix.value, "i")?, + Seqner::new(parse_sn(p.sn)?), + parse_qb64_diger(p.said.value, "d")?, + verfers_from_parsed(&p.keys, "k")?, + tholder_from_parsed(&p.threshold)?, + digers_from_parsed(&p.next_keys, "n")?, + tholder_from_parsed(&p.next_threshold)?, + prefixers_from_parsed(&p.witnesses, "b")?, + witness_threshold_from_parsed(&p.witness_threshold)?, + config_from_parsed(&p.config)?, + anchors_from_parsed(&p.anchors)?, + )) +} + +fn build_delegated_inception(p: &ParsedDip<'_>) -> Result { + Ok(DelegatedInceptionEvent::new( + build_inception(&p.icp)?, + parse_qb64_identifier(p.delegator, "di")?, + )) +} + +/// `rot`/`drt` carry no `c` field on the wire; the config is always empty. +fn build_rotation(p: &ParsedRot<'_>) -> Result { + Ok(RotationEvent::new( + parse_qb64_identifier(p.prefix, "i")?, + Seqner::new(parse_sn(p.sn)?), + parse_qb64_diger(p.said.value, "d")?, + parse_qb64_diger(p.prior, "p")?, + verfers_from_parsed(&p.keys, "k")?, + tholder_from_parsed(&p.threshold)?, + digers_from_parsed(&p.next_keys, "n")?, + tholder_from_parsed(&p.next_threshold)?, + prefixers_from_parsed(&p.witness_additions, "ba")?, + prefixers_from_parsed(&p.witness_removals, "br")?, + witness_threshold_from_parsed(&p.witness_threshold)?, + vec![], + anchors_from_parsed(&p.anchors)?, + )) +} + +fn build_interaction(p: &ParsedIxn<'_>) -> Result { + Ok(InteractionEvent::new( + parse_qb64_identifier(p.prefix, "i")?, + Seqner::new(parse_sn(p.sn)?), + parse_qb64_diger(p.said.value, "d")?, + parse_qb64_diger(p.prior, "p")?, + anchors_from_parsed(&p.anchors)?, + )) +} + +// --------------------------------------------------------------------------- +// Strict conversion layer: parsed wire views -> domain primitives +// --------------------------------------------------------------------------- + +fn tholder_from_parsed(t: &ParsedTholder<'_>) -> Result { + match t { + ParsedTholder::Hex(s) => { + let n = u64::from_str_radix(s, 16).map_err(|_| SerderError::InvalidPrimitive { + field: "kt", + source: ValidationError::UnknownMatterCode(format!("invalid hex threshold: {s}")), + })?; + Ok(Tholder::Simple(n)) + } + ParsedTholder::Number(s) => { + let n = s + .parse::() + .map_err(|_| SerderError::InvalidPrimitive { + field: "kt", + source: ValidationError::UnknownMatterCode(format!( + "invalid integer threshold: {s}" + )), + })?; + Ok(Tholder::Simple(n)) + } + ParsedTholder::Weighted(clauses) => { + let parsed: Result>, SerderError> = clauses + .iter() + .map(|clause| clause.iter().map(|w| parse_weight(w)).collect()) + .collect(); + Ok(Tholder::Weighted(parsed?)) + } } - Ok(()) } -/// Verify a double-SAID event (icp, dip): both `d` and `i` are replaced with -/// placeholders before computing the digest. -fn verify_said_double(raw: &[u8], code: DigestCode) -> Result<(), SerderError> { - let mut value: Value = serde_json::from_slice(raw)?; - let obj = value - .as_object_mut() - .ok_or(SerderError::MissingField("d"))?; - - let original_said = obj - .get("d") - .and_then(Value::as_str) - .ok_or(SerderError::MissingField("d"))? - .to_owned(); - - let placeholder = said_placeholder(code)?; - obj.insert("d".to_owned(), Value::String(placeholder.clone())); - obj.insert("i".to_owned(), Value::String(placeholder)); - - let reser = serde_json::to_string(&value)?; - let computed = compute_digest(reser.as_bytes(), code)?; - let computed_qb64 = to_qb64_string(&computed); - - if original_said != computed_qb64 { - return Err(SerderError::SaidMismatch { - expected: original_said, - computed: computed_qb64, - }); +fn witness_threshold_from_parsed(c: &ParsedCount<'_>) -> Result { + let n = match c { + ParsedCount::Hex(s) => { + u128::from_str_radix(s, 16).map_err(|_| SerderError::InvalidPrimitive { + field: "bt", + source: ValidationError::UnknownMatterCode(format!("invalid hex bt: {s}")), + })? + } + ParsedCount::Number(s) => s + .parse::() + .map_err(|_| SerderError::InvalidPrimitive { + field: "bt", + source: ValidationError::UnknownMatterCode(format!("invalid integer bt: {s}")), + })?, + }; + u32::try_from(n).map_err(|_| SerderError::InvalidPrimitive { + field: "bt", + source: ValidationError::UnknownMatterCode(format!( + "witness threshold {n} exceeds u32::MAX" + )), + }) +} + +fn seal_from_parsed(seal: &ParsedSeal<'_>) -> Result { + match seal { + ParsedSeal::Digest { d } => Ok(Seal::Digest { + d: parse_qb64_saider(d, "d")?, + }), + ParsedSeal::Root { rd } => Ok(Seal::Root { + rd: parse_qb64_saider(rd, "rd")?, + }), + ParsedSeal::Source { s, d } => Ok(Seal::Source { + s: Seqner::new(parse_sn(s)?), + d: parse_qb64_saider(d, "d")?, + }), + ParsedSeal::Event { i, s, d } => Ok(Seal::Event { + i: parse_qb64_prefixer(i, "i")?, + s: Seqner::new(parse_sn(s)?), + d: parse_qb64_saider(d, "d")?, + }), + ParsedSeal::Last { i } => Ok(Seal::Last { + i: parse_qb64_prefixer(i, "i")?, + }), } - Ok(()) +} + +// `UnknownIlk` for a bad config code replicates the tolerant path's exact +// behavior (see `reference::parse_config_array`) — kept for parity even +// though the variant name is odd. +fn config_from_parsed(config: &[&str]) -> Result, SerderError> { + config + .iter() + .map(|s| ConfigTrait::from_code(s).map_err(|_| SerderError::UnknownIlk((*s).to_owned()))) + .collect() +} + +fn verfers_from_parsed( + items: &[&str], + field: &'static str, +) -> Result>, SerderError> { + items.iter().map(|s| parse_qb64_verfer(s, field)).collect() +} + +fn prefixers_from_parsed( + items: &[&str], + field: &'static str, +) -> Result>, SerderError> { + items + .iter() + .map(|s| parse_qb64_prefixer(s, field)) + .collect() +} + +fn digers_from_parsed( + items: &[&str], + field: &'static str, +) -> Result>, SerderError> { + items.iter().map(|s| parse_qb64_diger(s, field)).collect() +} + +fn anchors_from_parsed(anchors: &[ParsedSeal<'_>]) -> Result, SerderError> { + anchors.iter().map(seal_from_parsed).collect() } // --------------------------------------------------------------------------- @@ -445,120 +445,6 @@ fn parse_sn(s: &str) -> Result { }) } -fn parse_witness_threshold(val: &Value) -> Result { - if let Some(n) = val.as_u64() { - return u32::try_from(n).map_err(|_| SerderError::InvalidPrimitive { - field: "bt", - source: ValidationError::UnknownMatterCode(format!( - "witness threshold {n} exceeds u32::MAX" - )), - }); - } - let s = val.as_str().ok_or(SerderError::MissingField("bt"))?; - let n = u128::from_str_radix(s, 16).map_err(|_| SerderError::InvalidPrimitive { - field: "bt", - source: ValidationError::UnknownMatterCode(format!("invalid hex bt: {s}")), - })?; - u32::try_from(n).map_err(|_| SerderError::InvalidPrimitive { - field: "bt", - source: ValidationError::UnknownMatterCode(format!( - "witness threshold {n} exceeds u32::MAX" - )), - }) -} - -// --------------------------------------------------------------------------- -// Array parsing helpers -// --------------------------------------------------------------------------- - -fn parse_qb64_prefixer_array(val: &Value) -> Result>, SerderError> { - let arr = val.as_array().ok_or(SerderError::MissingField("b"))?; - arr.iter() - .map(|v| { - let s = v.as_str().ok_or(SerderError::MissingField("b"))?; - parse_qb64_prefixer(s, "b") - }) - .collect() -} - -fn parse_qb64_verfer_array(val: &Value) -> Result>, SerderError> { - let arr = val.as_array().ok_or(SerderError::MissingField("k"))?; - arr.iter() - .map(|v| { - let s = v.as_str().ok_or(SerderError::MissingField("k"))?; - parse_qb64_verfer(s, "k") - }) - .collect() -} - -fn parse_qb64_diger_array(val: &Value) -> Result>, SerderError> { - let arr = val.as_array().ok_or(SerderError::MissingField("n"))?; - arr.iter() - .map(|v| { - let s = v.as_str().ok_or(SerderError::MissingField("n"))?; - parse_qb64_diger(s, "n") - }) - .collect() -} - -// --------------------------------------------------------------------------- -// Tholder parsing -// --------------------------------------------------------------------------- - -fn tholder_from_json(val: &Value) -> Result { - if let Some(s) = val.as_str() { - let n = u64::from_str_radix(s, 16).map_err(|_| SerderError::InvalidPrimitive { - field: "kt", - source: ValidationError::UnknownMatterCode(format!("invalid hex threshold: {s}")), - })?; - return Ok(Tholder::Simple(n)); - } - - if let Some(n) = val.as_u64() { - return Ok(Tholder::Simple(n)); - } - - if let Some(outer) = val.as_array() { - // keripy flattens single-clause weighted thresholds: [["1/2","1/2"]] - // becomes ["1/2","1/2"]. Detect flat vs nested by checking if the - // first element is a string (flat) or an array (nested). - let is_flat = outer.first().is_some_and(Value::is_string); - - let clauses: Result>, SerderError> = if is_flat { - // Flat list of fraction strings → single clause - let clause: Result, SerderError> = outer - .iter() - .map(|frac_val| { - let frac_str = frac_val.as_str().ok_or(SerderError::MissingField("kt"))?; - parse_weight(frac_str) - }) - .collect(); - Ok(vec![clause?]) - } else { - // Nested list of lists - outer - .iter() - .map(|clause_val| { - let clause_arr = clause_val - .as_array() - .ok_or(SerderError::MissingField("kt"))?; - clause_arr - .iter() - .map(|frac_val| { - let frac_str = - frac_val.as_str().ok_or(SerderError::MissingField("kt"))?; - parse_weight(frac_str) - }) - .collect() - }) - .collect() - }; - return Ok(Tholder::Weighted(clauses?)); - } - - Err(SerderError::MissingField("kt")) -} - fn parse_weight(s: &str) -> Result<(u64, u64), SerderError> { if let Some((num_s, den_s)) = s.split_once('/') { let num: u64 = num_s.parse().map_err(|_| SerderError::InvalidPrimitive { @@ -589,98 +475,6 @@ fn parse_weight(s: &str) -> Result<(u64, u64), SerderError> { } } -// --------------------------------------------------------------------------- -// Seal parsing -// --------------------------------------------------------------------------- - -fn seal_from_json(val: &Value) -> Result { - let obj = val.as_object().ok_or(SerderError::MissingField("a"))?; - - let has = |k: &str| obj.contains_key(k); - let n = obj.len(); - - // Match by key presence (order-independent) and field count. - if has("i") && has("s") && has("d") && n == 3 { - let i = parse_qb64_prefixer( - obj["i"].as_str().ok_or(SerderError::MissingField("i"))?, - "i", - )?; - let s_val = parse_sn(obj["s"].as_str().ok_or(SerderError::MissingField("s"))?)?; - let digest = parse_qb64_saider( - obj["d"].as_str().ok_or(SerderError::MissingField("d"))?, - "d", - )?; - Ok(Seal::Event { - i, - s: Seqner::new(s_val), - d: digest, - }) - } else if has("s") && has("d") && n == 2 { - let s_val = parse_sn(obj["s"].as_str().ok_or(SerderError::MissingField("s"))?)?; - let digest = parse_qb64_saider( - obj["d"].as_str().ok_or(SerderError::MissingField("d"))?, - "d", - )?; - Ok(Seal::Source { - s: Seqner::new(s_val), - d: digest, - }) - } else if has("rd") && n == 1 { - let root_digest = parse_qb64_saider( - obj["rd"].as_str().ok_or(SerderError::MissingField("rd"))?, - "rd", - )?; - Ok(Seal::Root { rd: root_digest }) - } else if has("d") && n == 1 { - let digest = parse_qb64_saider( - obj["d"].as_str().ok_or(SerderError::MissingField("d"))?, - "d", - )?; - Ok(Seal::Digest { d: digest }) - } else if has("i") && n == 1 { - let i = parse_qb64_prefixer( - obj["i"].as_str().ok_or(SerderError::MissingField("i"))?, - "i", - )?; - Ok(Seal::Last { i }) - } else { - Err(SerderError::MissingField("a")) - } -} - -fn parse_seal_array(val: &Value) -> Result, SerderError> { - let arr = val.as_array().ok_or(SerderError::MissingField("a"))?; - arr.iter().map(seal_from_json).collect() -} - -// --------------------------------------------------------------------------- -// Config parsing -// --------------------------------------------------------------------------- - -fn parse_config_array(val: &Value) -> Result, SerderError> { - let arr = val.as_array().ok_or(SerderError::MissingField("c"))?; - arr.iter() - .map(|v| { - let s = v.as_str().ok_or(SerderError::MissingField("c"))?; - ConfigTrait::from_code(s).map_err(|_| SerderError::UnknownIlk(s.to_owned())) - }) - .collect() -} - -// --------------------------------------------------------------------------- -// JSON field access helpers -// --------------------------------------------------------------------------- - -fn get_str<'a>(val: &'a Value, field: &'static str) -> Result<&'a str, SerderError> { - val.get(field) - .and_then(Value::as_str) - .ok_or(SerderError::MissingField(field)) -} - -fn get_field<'a>(val: &'a Value, field: &'static str) -> Result<&'a Value, SerderError> { - val.get(field).ok_or(SerderError::MissingField(field)) -} - #[cfg(test)] mod tests { use super::*; @@ -692,11 +486,14 @@ mod tests { DelegatedInceptionEvent, DelegatedRotationEvent, InceptionEvent, InteractionEvent, RotationEvent, }; + use crate::serder::primitives::to_qb64_string; + use crate::serder::said::{compute_digest, said_placeholder}; use crate::serder::serialize::{ serialize, serialize_delegated_inception, serialize_delegated_rotation, serialize_inception, serialize_interaction, serialize_rotation, }; use alloc::borrow::Cow; + use serde_json::Value; fn make_prefixer() -> Prefixer<'static> { MatterBuilder::new() @@ -1032,97 +829,6 @@ mod tests { ); } - // ----------------------------------------------------------------------- - // Tholder parsing - // ----------------------------------------------------------------------- - - #[test] - fn tholder_simple_from_json() { - let val = Value::String("3".to_owned()); - let th = tholder_from_json(&val).unwrap(); - assert_eq!(th, Tholder::Simple(3)); - } - - #[test] - fn tholder_weighted_from_json() { - let val = serde_json::json!([["1/2", "1/2"], ["1/3", "1/3", "1/3"]]); - let th = tholder_from_json(&val).unwrap(); - assert_eq!( - th, - Tholder::Weighted(vec![vec![(1, 2), (1, 2)], vec![(1, 3), (1, 3), (1, 3)],]) - ); - } - - #[test] - fn tholder_invalid_returns_error() { - let val = Value::Bool(true); - let result = tholder_from_json(&val); - assert!(result.is_err()); - } - - // ----------------------------------------------------------------------- - // Seal parsing - // ----------------------------------------------------------------------- - - #[test] - fn seal_digest_from_json() { - let saider = make_saider(); - let qb64_str = qb64(&saider); - let val = serde_json::json!({"d": qb64_str}); - let seal = seal_from_json(&val).unwrap(); - assert!(matches!(seal, Seal::Digest { .. })); - } - - #[test] - fn seal_root_from_json() { - let saider = make_saider(); - let qb64_str = qb64(&saider); - let val = serde_json::json!({"rd": qb64_str}); - let seal = seal_from_json(&val).unwrap(); - assert!(matches!(seal, Seal::Root { .. })); - } - - #[test] - fn seal_source_from_json() { - let saider = make_saider(); - let qb64_str = qb64(&saider); - let val = serde_json::json!({"s": "1", "d": qb64_str}); - let seal = seal_from_json(&val).unwrap(); - let Seal::Source { s, d } = seal else { - unreachable!() - }; - assert_eq!(s.value(), 1); - assert_eq!(qb64(&d), qb64_str); - } - - #[test] - fn seal_event_from_json() { - let saider = make_saider(); - let prefixer = make_prefixer(); - let d_str = qb64(&saider); - let i_str = qb64(&prefixer); - let val = serde_json::json!({"i": i_str, "s": "a", "d": d_str}); - let seal = seal_from_json(&val).unwrap(); - let Seal::Event { i, s, d } = seal else { - unreachable!() - }; - assert_eq!(qb64(&i), i_str); - assert_eq!(s.value(), 10); - assert_eq!(qb64(&d), d_str); - } - - #[test] - fn seal_last_from_json() { - let prefixer = make_prefixer(); - let i_str = qb64(&prefixer); - let val = serde_json::json!({"i": i_str}); - let seal = seal_from_json(&val).unwrap(); - let Seal::Last { i } = seal else { - unreachable!() - }; - assert_eq!(qb64(&i), i_str); - } - // ----------------------------------------------------------------------- // Seal roundtrips through serialize/deserialize // ----------------------------------------------------------------------- @@ -1280,24 +986,6 @@ mod tests { ); } - // ----------------------------------------------------------------------- - // intive=True integer threshold deserialization - // ----------------------------------------------------------------------- - - #[test] - fn tholder_from_json_integer() { - let val = serde_json::json!(2); - let tholder = tholder_from_json(&val).unwrap(); - assert_eq!(tholder, Tholder::Simple(2)); - } - - #[test] - fn parse_witness_threshold_integer() { - let val = serde_json::json!(3); - let bt = parse_witness_threshold(&val).unwrap(); - assert_eq!(bt, 3); - } - // ----------------------------------------------------------------------- // parse_weight handles boundary values // ----------------------------------------------------------------------- @@ -1474,6 +1162,86 @@ mod tests { ); } + // ----------------------------------------------------------------------- + // Strict-path behavior probes: intive acceptance and per-ilk rejection + // ----------------------------------------------------------------------- + + /// Rewrite the size field and recompute + splice the SAID so byte-level + /// surgery on a serialized event stays canonical and verifiable. + /// Single-SAID recomputation — valid for icp probes only when d != i + /// (`probe_icp` uses a basic prefix, so that holds). + fn resaid(mut raw: Vec) -> Vec { + let size = raw.len(); + let hex = format!("{size:06x}"); + raw[16..22].copy_from_slice(hex.as_bytes()); + let d_pos = raw.windows(5).position(|w| w == b"\"d\":\"").unwrap() + 5; + let span = d_pos..d_pos + 44; + let placeholder = said_placeholder(DigestCode::Blake3_256).unwrap(); + let mut scratch = raw.clone(); + scratch[span.clone()].copy_from_slice(placeholder.as_bytes()); + let computed = compute_digest(&scratch, DigestCode::Blake3_256).unwrap(); + let qb64_said = to_qb64_string(&computed); + raw[span].copy_from_slice(qb64_said.as_bytes()); + raw + } + + #[test] + fn intive_integer_bt_is_accepted() { + let raw = serialize_inception(&probe_icp()) + .unwrap() + .as_bytes() + .to_vec(); + let pos = raw.windows(9).position(|w| w == b"\"bt\":\"0\",").unwrap(); + let mut mutated = Vec::with_capacity(raw.len()); + mutated.extend_from_slice(&raw[..pos]); + mutated.extend_from_slice(b"\"bt\":0,"); + mutated.extend_from_slice(&raw[pos + 9..]); + let canonical_intive = resaid(mutated); + let event = deserialize_inception(&canonical_intive) + .expect("keripy intive=True integer bt must deserialize"); + assert_eq!(event.witness_threshold(), 0); + } + + #[test] + fn intive_integer_kt_is_accepted() { + let raw = serialize_inception(&probe_icp()) + .unwrap() + .as_bytes() + .to_vec(); + let pos = raw.windows(9).position(|w| w == b"\"kt\":\"1\",").unwrap(); + let mut mutated = Vec::with_capacity(raw.len()); + mutated.extend_from_slice(&raw[..pos]); + mutated.extend_from_slice(b"\"kt\":1,"); + mutated.extend_from_slice(&raw[pos + 9..]); + let canonical_intive = resaid(mutated); + let event = deserialize_inception(&canonical_intive) + .expect("keripy intive=True integer kt must deserialize"); + assert_eq!(*event.threshold(), Tholder::Simple(1)); + } + + #[test] + fn deserialize_rotation_rejects_drt_bytes() { + let drt = DelegatedRotationEvent::new(probe_rot()); + let raw = serialize_delegated_rotation(&drt).unwrap(); + assert!(matches!( + deserialize_rotation(raw.as_bytes()), + Err(SerderError::NonCanonical { + expected: "rot", + .. + }) + )); + } + + #[test] + fn deserialize_inception_rejects_dip_bytes() { + let dip = DelegatedInceptionEvent::new(probe_icp(), make_prefixer().into()); + let raw = serialize_delegated_inception(&dip).unwrap(); + assert!( + deserialize_inception(raw.as_bytes()).is_err(), + "dip bytes must not silently deserialize as icp (delegator dropped)" + ); + } + // ----------------------------------------------------------------------- // Parse-failure routing: a malformed qb64 code is a parsing-domain error // ----------------------------------------------------------------------- diff --git a/cesr/src/serder/deserialize/canonical.rs b/cesr/src/serder/deserialize/canonical.rs index ff42f09..9adb0b2 100644 --- a/cesr/src/serder/deserialize/canonical.rs +++ b/cesr/src/serder/deserialize/canonical.rs @@ -885,7 +885,7 @@ mod tests { #[test] fn scanner_expect_reports_offset_and_found() { let mut sc = Scanner::new(b"abc"); - let err = sc.expect("abd").unwrap_err(); + let err = sc.expect("abX").unwrap_err(); assert!(matches!( err, SerderError::NonCanonical { diff --git a/cesr/src/serder/deserialize/reference.rs b/cesr/src/serder/deserialize/reference.rs new file mode 100644 index 0000000..dafaef6 --- /dev/null +++ b/cesr/src/serder/deserialize/reference.rs @@ -0,0 +1,856 @@ +//! The pre-#142 tolerant read path (`serde_json::Value` + re-render SAID +//! verification), preserved verbatim as the differential-test oracle for +//! the strict canonical parser. Test-only: never compiled into production. + +use super::{ + infer_digest_code, parse_qb64_diger, parse_qb64_identifier, parse_qb64_prefixer, + parse_qb64_saider, parse_qb64_verfer, parse_sn, parse_weight, +}; +use crate::core::matter::code::DigestCode; +use crate::core::matter::error::ValidationError; +use crate::core::primitives::{Diger, Prefixer, Seqner, Tholder, Verfer}; +use crate::keri::{ + ConfigTrait, DelegatedInceptionEvent, DelegatedRotationEvent, Ilk, InceptionEvent, + InteractionEvent, KeriEvent, RotationEvent, Seal, +}; +#[allow( + unused_imports, + reason = "alloc prelude items; subset used per cfg/feature combination" +)] +use alloc::{borrow::ToOwned, format, string::String, string::ToString, vec, vec::Vec}; +use serde_json::Value; + +use crate::serder::error::SerderError; +use crate::serder::primitives::to_qb64_string; +use crate::serder::said::{compute_digest, said_placeholder}; +use crate::serder::version::{SerKind, VERSION_STRING_LEN, VersionString}; + +// --------------------------------------------------------------------------- +// Tolerant deserialization entry points (oracle) +// --------------------------------------------------------------------------- + +/// Deserialize any KERI event from canonical JSON bytes (tolerant oracle). +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn deserialize_event(raw: &[u8]) -> Result { + validate_version_string(raw)?; + let val: Value = serde_json::from_slice(raw)?; + let ilk_str = get_str(&val, "t")?; + let ilk = Ilk::from_code(ilk_str).map_err(|_| SerderError::UnknownIlk(ilk_str.to_owned()))?; + + match ilk { + Ilk::Icp => Ok(KeriEvent::Inception(deserialize_inception(raw)?)), + Ilk::Rot => Ok(KeriEvent::Rotation(deserialize_rotation(raw)?)), + Ilk::Ixn => Ok(KeriEvent::Interaction(deserialize_interaction(raw)?)), + Ilk::Dip => Ok(KeriEvent::DelegatedInception( + deserialize_delegated_inception(raw)?, + )), + Ilk::Drt => Ok(KeriEvent::DelegatedRotation( + deserialize_delegated_rotation(raw)?, + )), + _ => Err(SerderError::UnknownIlk(ilk_str.to_owned())), + } +} + +/// Deserialize an inception event from canonical JSON bytes (tolerant oracle). +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn deserialize_inception(raw: &[u8]) -> Result { + validate_version_string(raw)?; + let val: Value = serde_json::from_slice(raw)?; + let digest_code = infer_digest_code(get_str(&val, "d")?)?; + let d_str = get_str(&val, "d")?; + let i_str = get_str(&val, "i")?; + + if d_str == i_str { + verify_said_double(raw, digest_code)?; + } else { + verify_said_single(raw, digest_code)?; + } + + let said = parse_qb64_diger(get_str(&val, "d")?, "d")?; + let prefix = parse_qb64_identifier(get_str(&val, "i")?, "i")?; + let sn = parse_sn(get_str(&val, "s")?)?; + let threshold = tholder_from_json(get_field(&val, "kt")?)?; + let keys = parse_qb64_verfer_array(get_field(&val, "k")?)?; + let next_threshold = tholder_from_json(get_field(&val, "nt")?)?; + let next_keys = parse_qb64_diger_array(get_field(&val, "n")?)?; + let witness_threshold = parse_witness_threshold(get_field(&val, "bt")?)?; + let witnesses = parse_qb64_prefixer_array(get_field(&val, "b")?)?; + let config = parse_config_array(get_field(&val, "c")?)?; + let anchors = parse_seal_array(get_field(&val, "a")?)?; + + Ok(InceptionEvent::new( + prefix, + Seqner::new(sn), + said, + keys, + threshold, + next_keys, + next_threshold, + witnesses, + witness_threshold, + config, + anchors, + )) +} + +/// Deserialize a rotation event from canonical JSON bytes (tolerant oracle). +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn deserialize_rotation(raw: &[u8]) -> Result { + validate_version_string(raw)?; + let val: Value = serde_json::from_slice(raw)?; + let digest_code = infer_digest_code(get_str(&val, "d")?)?; + + verify_said_single(raw, digest_code)?; + + let said = parse_qb64_diger(get_str(&val, "d")?, "d")?; + let prefix = parse_qb64_identifier(get_str(&val, "i")?, "i")?; + let sn = parse_sn(get_str(&val, "s")?)?; + let prior_event_said = parse_qb64_diger(get_str(&val, "p")?, "p")?; + let threshold = tholder_from_json(get_field(&val, "kt")?)?; + let keys = parse_qb64_verfer_array(get_field(&val, "k")?)?; + let next_threshold = tholder_from_json(get_field(&val, "nt")?)?; + let next_keys = parse_qb64_diger_array(get_field(&val, "n")?)?; + let witness_threshold = parse_witness_threshold(get_field(&val, "bt")?)?; + let witness_removals = parse_qb64_prefixer_array(get_field(&val, "br")?)?; + let witness_additions = parse_qb64_prefixer_array(get_field(&val, "ba")?)?; + let config = match val.get("c") { + Some(c_val) => parse_config_array(c_val)?, + None => vec![], + }; + let anchors = parse_seal_array(get_field(&val, "a")?)?; + + Ok(RotationEvent::new( + prefix, + Seqner::new(sn), + said, + prior_event_said, + keys, + threshold, + next_keys, + next_threshold, + witness_additions, + witness_removals, + witness_threshold, + config, + anchors, + )) +} + +/// Deserialize an interaction event from canonical JSON bytes (tolerant oracle). +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn deserialize_interaction(raw: &[u8]) -> Result { + validate_version_string(raw)?; + let val: Value = serde_json::from_slice(raw)?; + let digest_code = infer_digest_code(get_str(&val, "d")?)?; + + verify_said_single(raw, digest_code)?; + + let said = parse_qb64_diger(get_str(&val, "d")?, "d")?; + let prefix = parse_qb64_identifier(get_str(&val, "i")?, "i")?; + let sn = parse_sn(get_str(&val, "s")?)?; + let prior_event_said = parse_qb64_diger(get_str(&val, "p")?, "p")?; + let anchors = parse_seal_array(get_field(&val, "a")?)?; + + Ok(InteractionEvent::new( + prefix, + Seqner::new(sn), + said, + prior_event_said, + anchors, + )) +} + +/// Deserialize a delegated inception event from canonical JSON bytes +/// (tolerant oracle). +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn deserialize_delegated_inception( + raw: &[u8], +) -> Result { + validate_version_string(raw)?; + let val: Value = serde_json::from_slice(raw)?; + let digest_code = infer_digest_code(get_str(&val, "d")?)?; + let d_str = get_str(&val, "d")?; + let i_str = get_str(&val, "i")?; + + if d_str == i_str { + verify_said_double(raw, digest_code)?; + } else { + verify_said_single(raw, digest_code)?; + } + + let said = parse_qb64_diger(get_str(&val, "d")?, "d")?; + let prefix = parse_qb64_identifier(get_str(&val, "i")?, "i")?; + let sn = parse_sn(get_str(&val, "s")?)?; + let threshold = tholder_from_json(get_field(&val, "kt")?)?; + let keys = parse_qb64_verfer_array(get_field(&val, "k")?)?; + let next_threshold = tholder_from_json(get_field(&val, "nt")?)?; + let next_keys = parse_qb64_diger_array(get_field(&val, "n")?)?; + let witness_threshold = parse_witness_threshold(get_field(&val, "bt")?)?; + let witnesses = parse_qb64_prefixer_array(get_field(&val, "b")?)?; + let config = parse_config_array(get_field(&val, "c")?)?; + let anchors = parse_seal_array(get_field(&val, "a")?)?; + let delegator = parse_qb64_identifier(get_str(&val, "di")?, "di")?; + + Ok(DelegatedInceptionEvent::new( + InceptionEvent::new( + prefix, + Seqner::new(sn), + said, + keys, + threshold, + next_keys, + next_threshold, + witnesses, + witness_threshold, + config, + anchors, + ), + delegator, + )) +} + +/// Deserialize a delegated rotation event from canonical JSON bytes +/// (tolerant oracle). +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn deserialize_delegated_rotation( + raw: &[u8], +) -> Result { + let rotation = deserialize_rotation(raw)?; + Ok(DelegatedRotationEvent::new(rotation)) +} + +// --------------------------------------------------------------------------- +// Version string validation +// --------------------------------------------------------------------------- + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn validate_version_string(raw: &[u8]) -> Result<(), SerderError> { + let val: Value = serde_json::from_slice(raw)?; + let vs_str = val + .get("v") + .and_then(Value::as_str) + .ok_or(SerderError::MissingField("v"))?; + + if vs_str.len() < VERSION_STRING_LEN { + return Err(SerderError::InvalidVersionString(format!( + "version string too short: {}", + vs_str.len() + ))); + } + let vs = VersionString::parse(vs_str)?; + if vs.kind != SerKind::Json { + return Err(SerderError::InvalidVersionString(format!( + "expected JSON, got {}", + vs.kind.as_str() + ))); + } + let expected_size = + usize::try_from(vs.size).map_err(|e| SerderError::InvalidVersionString(e.to_string()))?; + if expected_size != raw.len() { + return Err(SerderError::InvalidVersionString(format!( + "version string size {} does not match actual size {}", + expected_size, + raw.len() + ))); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// SAID verification helpers +// --------------------------------------------------------------------------- + +/// Verify a single-SAID event (rot, ixn, drt): only `d` is replaced with a +/// placeholder before computing the digest. +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn verify_said_single(raw: &[u8], code: DigestCode) -> Result<(), SerderError> { + let mut value: Value = serde_json::from_slice(raw)?; + let obj = value + .as_object_mut() + .ok_or(SerderError::MissingField("d"))?; + + let original_said = obj + .get("d") + .and_then(Value::as_str) + .ok_or(SerderError::MissingField("d"))? + .to_owned(); + + let placeholder = said_placeholder(code)?; + obj.insert("d".to_owned(), Value::String(placeholder)); + + let reser = serde_json::to_string(&value)?; + let computed = compute_digest(reser.as_bytes(), code)?; + let computed_qb64 = to_qb64_string(&computed); + + if original_said != computed_qb64 { + return Err(SerderError::SaidMismatch { + expected: original_said, + computed: computed_qb64, + }); + } + Ok(()) +} + +/// Verify a double-SAID event (icp, dip): both `d` and `i` are replaced with +/// placeholders before computing the digest. +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn verify_said_double(raw: &[u8], code: DigestCode) -> Result<(), SerderError> { + let mut value: Value = serde_json::from_slice(raw)?; + let obj = value + .as_object_mut() + .ok_or(SerderError::MissingField("d"))?; + + let original_said = obj + .get("d") + .and_then(Value::as_str) + .ok_or(SerderError::MissingField("d"))? + .to_owned(); + + let placeholder = said_placeholder(code)?; + obj.insert("d".to_owned(), Value::String(placeholder.clone())); + obj.insert("i".to_owned(), Value::String(placeholder)); + + let reser = serde_json::to_string(&value)?; + let computed = compute_digest(reser.as_bytes(), code)?; + let computed_qb64 = to_qb64_string(&computed); + + if original_said != computed_qb64 { + return Err(SerderError::SaidMismatch { + expected: original_said, + computed: computed_qb64, + }); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Array parsing helpers +// --------------------------------------------------------------------------- + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn parse_qb64_prefixer_array( + val: &Value, +) -> Result>, SerderError> { + let arr = val.as_array().ok_or(SerderError::MissingField("b"))?; + arr.iter() + .map(|v| { + let s = v.as_str().ok_or(SerderError::MissingField("b"))?; + parse_qb64_prefixer(s, "b") + }) + .collect() +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn parse_qb64_verfer_array(val: &Value) -> Result>, SerderError> { + let arr = val.as_array().ok_or(SerderError::MissingField("k"))?; + arr.iter() + .map(|v| { + let s = v.as_str().ok_or(SerderError::MissingField("k"))?; + parse_qb64_verfer(s, "k") + }) + .collect() +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn parse_qb64_diger_array(val: &Value) -> Result>, SerderError> { + let arr = val.as_array().ok_or(SerderError::MissingField("n"))?; + arr.iter() + .map(|v| { + let s = v.as_str().ok_or(SerderError::MissingField("n"))?; + parse_qb64_diger(s, "n") + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Tholder parsing +// --------------------------------------------------------------------------- + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn tholder_from_json(val: &Value) -> Result { + if let Some(s) = val.as_str() { + let n = u64::from_str_radix(s, 16).map_err(|_| SerderError::InvalidPrimitive { + field: "kt", + source: ValidationError::UnknownMatterCode(format!("invalid hex threshold: {s}")), + })?; + return Ok(Tholder::Simple(n)); + } + + if let Some(n) = val.as_u64() { + return Ok(Tholder::Simple(n)); + } + + if let Some(outer) = val.as_array() { + // keripy flattens single-clause weighted thresholds: [["1/2","1/2"]] + // becomes ["1/2","1/2"]. Detect flat vs nested by checking if the + // first element is a string (flat) or an array (nested). + let is_flat = outer.first().is_some_and(Value::is_string); + + let clauses: Result>, SerderError> = if is_flat { + // Flat list of fraction strings → single clause + let clause: Result, SerderError> = outer + .iter() + .map(|frac_val| { + let frac_str = frac_val.as_str().ok_or(SerderError::MissingField("kt"))?; + parse_weight(frac_str) + }) + .collect(); + Ok(vec![clause?]) + } else { + // Nested list of lists + outer + .iter() + .map(|clause_val| { + let clause_arr = clause_val + .as_array() + .ok_or(SerderError::MissingField("kt"))?; + clause_arr + .iter() + .map(|frac_val| { + let frac_str = + frac_val.as_str().ok_or(SerderError::MissingField("kt"))?; + parse_weight(frac_str) + }) + .collect() + }) + .collect() + }; + return Ok(Tholder::Weighted(clauses?)); + } + + Err(SerderError::MissingField("kt")) +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn parse_witness_threshold(val: &Value) -> Result { + if let Some(n) = val.as_u64() { + return u32::try_from(n).map_err(|_| SerderError::InvalidPrimitive { + field: "bt", + source: ValidationError::UnknownMatterCode(format!( + "witness threshold {n} exceeds u32::MAX" + )), + }); + } + let s = val.as_str().ok_or(SerderError::MissingField("bt"))?; + let n = u128::from_str_radix(s, 16).map_err(|_| SerderError::InvalidPrimitive { + field: "bt", + source: ValidationError::UnknownMatterCode(format!("invalid hex bt: {s}")), + })?; + u32::try_from(n).map_err(|_| SerderError::InvalidPrimitive { + field: "bt", + source: ValidationError::UnknownMatterCode(format!( + "witness threshold {n} exceeds u32::MAX" + )), + }) +} + +// --------------------------------------------------------------------------- +// Seal parsing +// --------------------------------------------------------------------------- + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn seal_from_json(val: &Value) -> Result { + let obj = val.as_object().ok_or(SerderError::MissingField("a"))?; + + let has = |k: &str| obj.contains_key(k); + let n = obj.len(); + + // Match by key presence (order-independent) and field count. + if has("i") && has("s") && has("d") && n == 3 { + let i = parse_qb64_prefixer( + obj["i"].as_str().ok_or(SerderError::MissingField("i"))?, + "i", + )?; + let s_val = parse_sn(obj["s"].as_str().ok_or(SerderError::MissingField("s"))?)?; + let digest = parse_qb64_saider( + obj["d"].as_str().ok_or(SerderError::MissingField("d"))?, + "d", + )?; + Ok(Seal::Event { + i, + s: Seqner::new(s_val), + d: digest, + }) + } else if has("s") && has("d") && n == 2 { + let s_val = parse_sn(obj["s"].as_str().ok_or(SerderError::MissingField("s"))?)?; + let digest = parse_qb64_saider( + obj["d"].as_str().ok_or(SerderError::MissingField("d"))?, + "d", + )?; + Ok(Seal::Source { + s: Seqner::new(s_val), + d: digest, + }) + } else if has("rd") && n == 1 { + let root_digest = parse_qb64_saider( + obj["rd"].as_str().ok_or(SerderError::MissingField("rd"))?, + "rd", + )?; + Ok(Seal::Root { rd: root_digest }) + } else if has("d") && n == 1 { + let digest = parse_qb64_saider( + obj["d"].as_str().ok_or(SerderError::MissingField("d"))?, + "d", + )?; + Ok(Seal::Digest { d: digest }) + } else if has("i") && n == 1 { + let i = parse_qb64_prefixer( + obj["i"].as_str().ok_or(SerderError::MissingField("i"))?, + "i", + )?; + Ok(Seal::Last { i }) + } else { + Err(SerderError::MissingField("a")) + } +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn parse_seal_array(val: &Value) -> Result, SerderError> { + let arr = val.as_array().ok_or(SerderError::MissingField("a"))?; + arr.iter().map(seal_from_json).collect() +} + +// --------------------------------------------------------------------------- +// Config parsing +// --------------------------------------------------------------------------- + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn parse_config_array(val: &Value) -> Result, SerderError> { + let arr = val.as_array().ok_or(SerderError::MissingField("c"))?; + arr.iter() + .map(|v| { + let s = v.as_str().ok_or(SerderError::MissingField("c"))?; + ConfigTrait::from_code(s).map_err(|_| SerderError::UnknownIlk(s.to_owned())) + }) + .collect() +} + +// --------------------------------------------------------------------------- +// JSON field access helpers +// --------------------------------------------------------------------------- + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn get_str<'a>(val: &'a Value, field: &'static str) -> Result<&'a str, SerderError> { + val.get(field) + .and_then(Value::as_str) + .ok_or(SerderError::MissingField(field)) +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn get_field<'a>(val: &'a Value, field: &'static str) -> Result<&'a Value, SerderError> { + val.get(field).ok_or(SerderError::MissingField(field)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::matter::builder::MatterBuilder; + use crate::core::matter::code::{CesrCode, DigestCode, VerKeyCode}; + use crate::core::primitives::{Prefixer, Saider}; + use crate::serder::serialize::{ + serialize_delegated_inception, serialize_delegated_rotation, serialize_inception, + serialize_interaction, serialize_rotation, + }; + use alloc::borrow::Cow; + + fn make_prefixer() -> Prefixer<'static> { + MatterBuilder::new() + .with_code(VerKeyCode::Ed25519) + .with_raw(Cow::<[u8]>::Owned(vec![0u8; 32])) + .unwrap() + .build() + .unwrap() + } + + fn make_saider() -> Saider<'static> { + MatterBuilder::new() + .with_code(DigestCode::Blake3_256) + .with_raw(Cow::<[u8]>::Owned(vec![1u8; 32])) + .unwrap() + .build() + .unwrap() + } + + fn make_verfer() -> Verfer<'static> { + MatterBuilder::new() + .with_code(VerKeyCode::Ed25519) + .with_raw(Cow::<[u8]>::Owned(vec![1u8; 32])) + .unwrap() + .build() + .unwrap() + } + + fn qb64(m: &crate::core::matter::matter::Matter<'_, impl CesrCode>) -> String { + crate::serder::primitives::to_qb64_string(m) + } + + fn probe_icp() -> InceptionEvent { + InceptionEvent::new( + make_prefixer().into(), + Seqner::new(0), + make_saider(), + vec![make_verfer()], + Tholder::Simple(1), + vec![make_saider()], + Tholder::Simple(1), + vec![make_prefixer()], + 1, + vec![ConfigTrait::EstOnly], + vec![Seal::Digest { d: make_saider() }], + ) + } + + fn probe_rot() -> RotationEvent { + RotationEvent::new( + make_prefixer().into(), + Seqner::new(2), + make_saider(), + make_saider(), + vec![make_verfer()], + Tholder::Simple(1), + vec![make_saider()], + Tholder::Simple(1), + vec![make_prefixer()], + vec![], + 1, + vec![], + vec![], + ) + } + + // ----------------------------------------------------------------------- + // Oracle round-trips: every tolerant entry point stays green on writer + // output (the property the differential suite diffs the strict path + // against). + // ----------------------------------------------------------------------- + + #[test] + fn oracle_roundtrips_icp() { + let ser = serialize_inception(&probe_icp()).unwrap(); + let event = deserialize_inception(ser.as_bytes()).unwrap(); + assert_eq!(qb64(event.said()), qb64(ser.said())); + assert!(matches!( + deserialize_event(ser.as_bytes()).unwrap(), + KeriEvent::Inception(_) + )); + } + + #[test] + fn oracle_roundtrips_rot() { + let ser = serialize_rotation(&probe_rot()).unwrap(); + let event = deserialize_rotation(ser.as_bytes()).unwrap(); + assert_eq!(qb64(event.said()), qb64(ser.said())); + assert!(matches!( + deserialize_event(ser.as_bytes()).unwrap(), + KeriEvent::Rotation(_) + )); + } + + #[test] + fn oracle_roundtrips_ixn() { + let ixn = InteractionEvent::new( + make_prefixer().into(), + Seqner::new(3), + make_saider(), + make_saider(), + vec![Seal::Digest { d: make_saider() }], + ); + let ser = serialize_interaction(&ixn).unwrap(); + let event = deserialize_interaction(ser.as_bytes()).unwrap(); + assert_eq!(qb64(event.said()), qb64(ser.said())); + assert!(matches!( + deserialize_event(ser.as_bytes()).unwrap(), + KeriEvent::Interaction(_) + )); + } + + #[test] + fn oracle_roundtrips_dip() { + let dip = DelegatedInceptionEvent::new(probe_icp(), make_prefixer().into()); + let ser = serialize_delegated_inception(&dip).unwrap(); + let event = deserialize_delegated_inception(ser.as_bytes()).unwrap(); + assert_eq!(qb64(event.inception().said()), qb64(ser.said())); + assert!(matches!( + deserialize_event(ser.as_bytes()).unwrap(), + KeriEvent::DelegatedInception(_) + )); + } + + #[test] + fn oracle_roundtrips_drt() { + let drt = DelegatedRotationEvent::new(probe_rot()); + let ser = serialize_delegated_rotation(&drt).unwrap(); + let event = deserialize_delegated_rotation(ser.as_bytes()).unwrap(); + assert_eq!(qb64(event.rotation().said()), qb64(ser.said())); + assert!(matches!( + deserialize_event(ser.as_bytes()).unwrap(), + KeriEvent::DelegatedRotation(_) + )); + } + + // ----------------------------------------------------------------------- + // Tholder parsing + // ----------------------------------------------------------------------- + + #[test] + fn tholder_simple_from_json() { + let val = Value::String("3".to_owned()); + let th = tholder_from_json(&val).unwrap(); + assert_eq!(th, Tholder::Simple(3)); + } + + #[test] + fn tholder_weighted_from_json() { + let val = serde_json::json!([["1/2", "1/2"], ["1/3", "1/3", "1/3"]]); + let th = tholder_from_json(&val).unwrap(); + assert_eq!( + th, + Tholder::Weighted(vec![vec![(1, 2), (1, 2)], vec![(1, 3), (1, 3), (1, 3)],]) + ); + } + + #[test] + fn tholder_invalid_returns_error() { + let val = Value::Bool(true); + let result = tholder_from_json(&val); + assert!(result.is_err()); + } + + // ----------------------------------------------------------------------- + // Seal parsing + // ----------------------------------------------------------------------- + + #[test] + fn seal_digest_from_json() { + let saider = make_saider(); + let qb64_str = qb64(&saider); + let val = serde_json::json!({"d": qb64_str}); + let seal = seal_from_json(&val).unwrap(); + assert!(matches!(seal, Seal::Digest { .. })); + } + + #[test] + fn seal_root_from_json() { + let saider = make_saider(); + let qb64_str = qb64(&saider); + let val = serde_json::json!({"rd": qb64_str}); + let seal = seal_from_json(&val).unwrap(); + assert!(matches!(seal, Seal::Root { .. })); + } + + #[test] + fn seal_source_from_json() { + let saider = make_saider(); + let qb64_str = qb64(&saider); + let val = serde_json::json!({"s": "1", "d": qb64_str}); + let seal = seal_from_json(&val).unwrap(); + let Seal::Source { s, d } = seal else { + unreachable!() + }; + assert_eq!(s.value(), 1); + assert_eq!(qb64(&d), qb64_str); + } + + #[test] + fn seal_event_from_json() { + let saider = make_saider(); + let prefixer = make_prefixer(); + let d_str = qb64(&saider); + let i_str = qb64(&prefixer); + let val = serde_json::json!({"i": i_str, "s": "a", "d": d_str}); + let seal = seal_from_json(&val).unwrap(); + let Seal::Event { i, s, d } = seal else { + unreachable!() + }; + assert_eq!(qb64(&i), i_str); + assert_eq!(s.value(), 10); + assert_eq!(qb64(&d), d_str); + } + + #[test] + fn seal_last_from_json() { + let prefixer = make_prefixer(); + let i_str = qb64(&prefixer); + let val = serde_json::json!({"i": i_str}); + let seal = seal_from_json(&val).unwrap(); + let Seal::Last { i } = seal else { + unreachable!() + }; + assert_eq!(qb64(&i), i_str); + } + + // ----------------------------------------------------------------------- + // intive=True integer threshold deserialization + // ----------------------------------------------------------------------- + + #[test] + fn tholder_from_json_integer() { + let val = serde_json::json!(2); + let tholder = tholder_from_json(&val).unwrap(); + assert_eq!(tholder, Tholder::Simple(2)); + } + + #[test] + fn parse_witness_threshold_integer() { + let val = serde_json::json!(3); + let bt = parse_witness_threshold(&val).unwrap(); + assert_eq!(bt, 3); + } +} diff --git a/cesr/src/serder/said.rs b/cesr/src/serder/said.rs index bce2836..a1c39f7 100644 --- a/cesr/src/serder/said.rs +++ b/cesr/src/serder/said.rs @@ -16,9 +16,9 @@ use crate::crypto::digest::digest; reason = "alloc prelude items; subset used per cfg/feature combination" )] use alloc::{borrow::ToOwned, string::String, string::ToString, vec::Vec}; -#[cfg(test)] use core::ops::Range; +use crate::serder::deserialize::canonical::Spanned; use crate::serder::error::SerderError; use crate::serder::primitives::to_qb64_string; @@ -29,7 +29,6 @@ use crate::serder::primitives::to_qb64_string; pub const DUMMY_CHAR: char = '#'; /// Byte form of [`DUMMY_CHAR`] for in-place span filling. -#[cfg(test)] pub(crate) const DUMMY_BYTE: u8 = b'#'; /// Generate a placeholder string of the correct qb64 length for `code`. @@ -107,21 +106,13 @@ pub fn verify_said(raw: &[u8], code: DigestCode) -> Result { Ok(original_said == computed_qb64) } -// Test-gated until the deserialize entry points adopt span-based SAID -// verification (#142 rewire); the gate is removed when the first production -// caller lands. -#[cfg(test)] -#[allow( - clippy::redundant_pub_crate, - reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" -)] /// Verify a SAID by span: copy `raw` once into a scratch buffer, overwrite /// the SAID value span (and the prefix span for double-SAID events) with -/// [`DUMMY_BYTE`], hash, and compare against `said_value`. +/// [`DUMMY_BYTE`], hash, and compare against the SAID value. /// /// Spans come from the canonical parser and must address the qb64 value /// bytes exactly (quotes excluded). This replaces the historical -/// parse-mutate-re-render verification with one allocation and one hash. +/// parse-mutate-re-render verification with one raw copy and one hash. /// /// # Errors /// @@ -130,29 +121,27 @@ pub fn verify_said(raw: &[u8], code: DigestCode) -> Result { /// [`SerderError::DigestError`] on hash failure. pub(crate) fn verify_said_spans( raw: &[u8], - said_value: &str, - said_span: &Range, - prefix_span: Option<&Range>, + said: &Spanned<'_>, + prefix: Option<&Spanned<'_>>, code: DigestCode, ) -> Result<(), SerderError> { let mut scratch = raw.to_vec(); - fill_span(&mut scratch, said_span)?; - if let Some(span) = prefix_span { - fill_span(&mut scratch, span)?; + fill_span(&mut scratch, &said.span)?; + if let Some(p) = prefix { + fill_span(&mut scratch, &p.span)?; } let computed = compute_digest(&scratch, code)?; let computed_qb64 = to_qb64_string(&computed); - if said_value == computed_qb64 { + if said.value == computed_qb64 { Ok(()) } else { Err(SerderError::SaidMismatch { - expected: said_value.to_owned(), + expected: said.value.to_owned(), computed: computed_qb64, }) } } -#[cfg(test)] fn fill_span(scratch: &mut [u8], span: &Range) -> Result<(), SerderError> { scratch .get_mut(span.clone()) @@ -245,7 +234,8 @@ mod tests { + 5; let span = start..start + 44; assert_eq!(&raw[span.clone()], said.as_bytes()); - verify_said_spans(&raw, &said, &span, None, DigestCode::Blake3_256) + let spanned = Spanned { value: &said, span }; + verify_said_spans(&raw, &spanned, None, DigestCode::Blake3_256) .expect("writer output must verify"); } @@ -256,8 +246,9 @@ mod tests { let span = start..start + 44; let s_pos = raw.windows(8).position(|w| w == b",\"s\":\"1\"").unwrap(); raw[s_pos + 6] = b'2'; + let spanned = Spanned { value: &said, span }; assert!(matches!( - verify_said_spans(&raw, &said, &span, None, DigestCode::Blake3_256), + verify_said_spans(&raw, &spanned, None, DigestCode::Blake3_256), Err(SerderError::SaidMismatch { .. }) )); } @@ -265,13 +256,33 @@ mod tests { #[test] fn verify_said_spans_rejects_out_of_bounds_span() { let (raw, said) = probe_ixn_raw(); - let bogus = raw.len()..raw.len() + 44; + let bogus = Spanned { + value: &said, + span: raw.len()..raw.len() + 44, + }; assert!(matches!( - verify_said_spans(&raw, &said, &bogus, None, DigestCode::Blake3_256), + verify_said_spans(&raw, &bogus, None, DigestCode::Blake3_256), Err(SerderError::InvalidEventLayout(_)) )); } + #[test] + fn verify_said_spans_wrong_width_span_is_said_mismatch() { + // An in-bounds span of the wrong width (43 instead of 44 bytes) fills + // the wrong bytes and therefore computes a different digest — the + // failure surfaces as SaidMismatch, not a panic or a separate variant. + let (raw, said) = probe_ixn_raw(); + let start = raw.windows(6).position(|w| w == b"\"d\":\"E").unwrap() + 5; + let short = Spanned { + value: &said, + span: start..start + 43, + }; + assert!(matches!( + verify_said_spans(&raw, &short, None, DigestCode::Blake3_256), + Err(SerderError::SaidMismatch { .. }) + )); + } + #[test] fn verify_said_spans_double_said_matches_reference() { // For an icp whose d == i (self-addressing), filling BOTH spans must @@ -292,7 +303,15 @@ mod tests { let i_span = i_start..i_start + 44; assert_eq!(&raw[d_span.clone()], said.as_bytes()); assert_eq!(&raw[i_span.clone()], said.as_bytes()); - verify_said_spans(&raw, &said, &d_span, Some(&i_span), DigestCode::Blake3_256) + let d_spanned = Spanned { + value: &said, + span: d_span, + }; + let i_spanned = Spanned { + value: &said, + span: i_span, + }; + verify_said_spans(&raw, &d_spanned, Some(&i_spanned), DigestCode::Blake3_256) .expect("double-SAID writer output must verify by span"); } } diff --git a/cesr/src/serder/serialize/direct.rs b/cesr/src/serder/serialize/direct.rs index 7e66e6a..432ad76 100644 --- a/cesr/src/serder/serialize/direct.rs +++ b/cesr/src/serder/serialize/direct.rs @@ -681,6 +681,8 @@ mod tests { assert!(layout.prefix_slot.is_none(), "ixn is single-SAID"); } + // The read path is now the strict canonical parser (#142); the assertion + // is unchanged — direct output must still SAID-verify through it. #[test] fn direct_output_verifies_through_unchanged_read_path() { let event = InceptionEvent::new( @@ -701,7 +703,7 @@ mod tests { assert_eq!( to_qb64_string(parsed.said()), to_qb64_string(direct.said()), - "direct-rendered event must SAID-verify through the serde_json read path" + "direct-rendered event must SAID-verify through the strict canonical read path" ); } } From 600635c883987cdee0cddc0124679a5db79750dd Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Fri, 10 Jul 2026 23:18:01 +0200 Subject: [PATCH 10/21] feat(serder)!: verify_said goes strict and returns Result<(), SerderError> (#142) BREAKING: verify_said now parses with the strict canonical parser and returns Ok(()) / Err(SaidMismatch) instead of Result; non-canonical input fails with NonCanonical instead of being tolerated. Co-Authored-By: Claude Fable 5 --- cesr/src/serder/said.rs | 111 +++++++++++++++++++------------ cesr/src/serder/serialize/drt.rs | 5 +- cesr/src/serder/serialize/ixn.rs | 5 +- cesr/src/serder/serialize/rot.rs | 5 +- 4 files changed, 76 insertions(+), 50 deletions(-) diff --git a/cesr/src/serder/said.rs b/cesr/src/serder/said.rs index a1c39f7..13a399f 100644 --- a/cesr/src/serder/said.rs +++ b/cesr/src/serder/said.rs @@ -1,9 +1,13 @@ //! SAID (Self-Addressing IDentifier) computation and verification. //! //! A SAID is a content-addressable digest that appears in the `d` field of a -//! KERI event. To compute it, the `d` field is first filled with a placeholder -//! string of the correct length, the event is serialized, and the digest of -//! that serialization becomes the final `d` value. +//! KERI event. On the write path, the `d` field (and, for self-addressing +//! `icp`/`dip` events, the `i` field too) is first filled with a placeholder +//! string of the correct length ([`said_placeholder`]), the event is +//! serialized, and the digest of that serialization becomes the final field +//! value. On the read path, verification parses the event with the strict +//! canonical parser and fills the same byte spans in place over a single +//! scratch copy of the raw input, rather than re-rendering the event. use crate::core::matter::code::CesrCode; use crate::core::matter::code::DigestCode; @@ -18,7 +22,7 @@ use crate::crypto::digest::digest; use alloc::{borrow::ToOwned, string::String, string::ToString, vec::Vec}; use core::ops::Range; -use crate::serder::deserialize::canonical::Spanned; +use crate::serder::deserialize::canonical::{ParsedDip, ParsedEvent, Spanned, parse_event}; use crate::serder::error::SerderError; use crate::serder::primitives::to_qb64_string; @@ -63,47 +67,34 @@ pub fn compute_digest(data: &[u8], code: DigestCode) -> Result, digest(code, data).map_err(|e| SerderError::DigestError(e.to_string())) } -/// Verify that the `d` field of a serialized JSON event matches a freshly -/// computed SAID. +/// Verify that the `d` field of a serialized canonical event matches a +/// freshly computed SAID. /// -/// This only replaces the `d` field with a placeholder — suitable for -/// rotation (`rot`), interaction (`ixn`), and delegated rotation (`drt`) -/// events. For inception events where both `d` and `i` are saidive, use -/// the deserialization functions which handle double-SAID verification -/// internally. -/// -/// The function: -/// 1. Parses `raw` as JSON and reads the `d` field. -/// 2. Replaces `d` with a placeholder of the correct length for `code`. -/// 3. Re-serializes the JSON and computes the digest. -/// 4. Compares the computed digest (qb64) against the original `d` value. +/// Parses the event with the strict canonical parser, fills the `d` (and, +/// for `icp`/`dip` events whose prefix equals their SAID, the `i`) value +/// span with [`DUMMY_CHAR`] in a single scratch copy, hashes, and compares. /// /// # Errors /// -/// Returns [`SerderError::MissingField`] if there is no `d` field, -/// [`SerderError::DigestError`] on hash failure, or [`SerderError::Json`] -/// on parse failure. -pub fn verify_said(raw: &[u8], code: DigestCode) -> Result { - let mut value: serde_json::Value = serde_json::from_slice(raw)?; - - let obj = value - .as_object_mut() - .ok_or(SerderError::MissingField("d"))?; - - let original_said = obj - .get("d") - .and_then(serde_json::Value::as_str) - .ok_or(SerderError::MissingField("d"))? - .to_owned(); - - let placeholder = said_placeholder(code)?; - obj.insert("d".to_owned(), serde_json::Value::String(placeholder)); - - let reser = serde_json::to_string(&value)?; - let computed = compute_digest(reser.as_bytes(), code)?; - let computed_qb64 = to_qb64_string(&computed); - - Ok(original_said == computed_qb64) +/// Returns [`SerderError::SaidMismatch`] if the digest differs, +/// [`SerderError::NonCanonical`] or [`SerderError::InvalidVersionString`] +/// if the input is not a canonical event, or [`SerderError::DigestError`] +/// on hash failure. +pub fn verify_said(raw: &[u8], code: DigestCode) -> Result<(), SerderError> { + match parse_event(raw)? { + ParsedEvent::Inception(p) => { + let prefix = (p.said.value == p.prefix.value).then_some(&p.prefix); + verify_said_spans(raw, &p.said, prefix, code) + } + ParsedEvent::DelegatedInception(ParsedDip { icp, .. }) => { + let prefix = (icp.said.value == icp.prefix.value).then_some(&icp.prefix); + verify_said_spans(raw, &icp.said, prefix, code) + } + ParsedEvent::Rotation(p) | ParsedEvent::DelegatedRotation(p) => { + verify_said_spans(raw, &p.said, None, code) + } + ParsedEvent::Interaction(p) => verify_said_spans(raw, &p.said, None, code), + } } /// Verify a SAID by span: copy `raw` once into a scratch buffer, overwrite @@ -314,4 +305,42 @@ mod tests { verify_said_spans(&raw, &d_spanned, Some(&i_spanned), DigestCode::Blake3_256) .expect("double-SAID writer output must verify by span"); } + + #[test] + fn verify_said_accepts_serialized_event() { + let (raw, _) = probe_ixn_raw(); + verify_said(&raw, DigestCode::Blake3_256).expect("writer output must verify"); + } + + #[test] + fn verify_said_rejects_tampered_event() { + let (mut raw, _) = probe_ixn_raw(); + let s_pos = raw.windows(8).position(|w| w == b",\"s\":\"1\"").unwrap(); + raw[s_pos + 6] = b'2'; + assert!(matches!( + verify_said(&raw, DigestCode::Blake3_256), + Err(SerderError::SaidMismatch { .. }) + )); + } + + #[test] + fn verify_said_rejects_non_canonical_input() { + assert!(matches!( + verify_said(b"not an event", DigestCode::Blake3_256), + Err(SerderError::NonCanonical { .. } | SerderError::InvalidVersionString(_)) + )); + } + + #[test] + fn verify_said_double_said_inception_verifies() { + let verfer = MatterBuilder::new() + .with_code(VerKeyCode::Ed25519) + .with_raw(Cow::<[u8]>::Owned(vec![7u8; 32])) + .unwrap() + .build() + .unwrap(); + let icp = InceptionBuilder::new().keys(vec![verfer]).build().unwrap(); + verify_said(icp.as_bytes(), DigestCode::Blake3_256) + .expect("double-SAID inception must verify through the strict path"); + } } diff --git a/cesr/src/serder/serialize/drt.rs b/cesr/src/serder/serialize/drt.rs index 1f073b3..0120fab 100644 --- a/cesr/src/serder/serialize/drt.rs +++ b/cesr/src/serder/serialize/drt.rs @@ -212,9 +212,8 @@ mod tests { assert!(d.starts_with('E'), "Blake3_256 SAID should start with 'E'"); assert_eq!(d.len(), 44); - let valid = - crate::serder::said::verify_said(result.as_bytes(), DigestCode::Blake3_256).unwrap(); - assert!(valid, "SAID verification should pass"); + crate::serder::said::verify_said(result.as_bytes(), DigestCode::Blake3_256) + .expect("SAID verification should pass"); } #[test] diff --git a/cesr/src/serder/serialize/ixn.rs b/cesr/src/serder/serialize/ixn.rs index 3722c42..f55320e 100644 --- a/cesr/src/serder/serialize/ixn.rs +++ b/cesr/src/serder/serialize/ixn.rs @@ -179,9 +179,8 @@ mod tests { assert!(d.starts_with('E'), "Blake3_256 SAID should start with 'E'"); assert_eq!(d.len(), 44); - let valid = - crate::serder::said::verify_said(result.as_bytes(), DigestCode::Blake3_256).unwrap(); - assert!(valid, "SAID verification should pass"); + crate::serder::said::verify_said(result.as_bytes(), DigestCode::Blake3_256) + .expect("SAID verification should pass"); } #[test] diff --git a/cesr/src/serder/serialize/rot.rs b/cesr/src/serder/serialize/rot.rs index 9b1a330..076836f 100644 --- a/cesr/src/serder/serialize/rot.rs +++ b/cesr/src/serder/serialize/rot.rs @@ -206,9 +206,8 @@ mod tests { assert!(d.starts_with('E'), "Blake3_256 SAID should start with 'E'"); assert_eq!(d.len(), 44); - let valid = - crate::serder::said::verify_said(result.as_bytes(), DigestCode::Blake3_256).unwrap(); - assert!(valid, "SAID verification should pass"); + crate::serder::said::verify_said(result.as_bytes(), DigestCode::Blake3_256) + .expect("SAID verification should pass"); } #[test] From e44e2aa845aca1b6ec50c8ce6dc8a2c9156ee779 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Fri, 10 Jul 2026 23:26:26 +0200 Subject: [PATCH 11/21] docs(serder): document verify_said's explicit digest-code contract (#142) Co-Authored-By: Claude Fable 5 --- cesr/src/serder/said.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/cesr/src/serder/said.rs b/cesr/src/serder/said.rs index 13a399f..68a2762 100644 --- a/cesr/src/serder/said.rs +++ b/cesr/src/serder/said.rs @@ -74,6 +74,14 @@ pub fn compute_digest(data: &[u8], code: DigestCode) -> Result, /// for `icp`/`dip` events whose prefix equals their SAID, the `i`) value /// span with [`DUMMY_CHAR`] in a single scratch copy, hashes, and compares. /// +/// Unlike the deserializers — which infer the digest code from the `d` +/// value's own qb64 prefix — `code` is caller-supplied: a caller that +/// knows the expected algorithm out-of-band can reject events recomputed +/// under a different (possibly weaker) digest, which self-describing +/// inference cannot. Passing a code that does not match the SAID's own +/// derivation always yields [`SerderError::SaidMismatch`], never a false +/// accept: the computed qb64's code prefix differs from the `d` value's. +/// /// # Errors /// /// Returns [`SerderError::SaidMismatch`] if the digest differs, @@ -323,6 +331,15 @@ mod tests { )); } + #[test] + fn verify_said_wrong_code_is_said_mismatch() { + let (raw, _) = probe_ixn_raw(); + assert!(matches!( + verify_said(&raw, DigestCode::SHA3_256), + Err(SerderError::SaidMismatch { .. }) + )); + } + #[test] fn verify_said_rejects_non_canonical_input() { assert!(matches!( From f4dbb8fd72acc42d8c9228fa7de91f90a05cc5b1 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Fri, 10 Jul 2026 23:31:07 +0200 Subject: [PATCH 12/21] refactor(serder): extract shared event proptest strategies (#142) Co-Authored-By: Claude Fable 5 --- cesr/src/serder/event_strategies.rs | 319 ++++++++++++++++++++++++++++ cesr/src/serder/mod.rs | 4 + cesr/src/serder/serialize/direct.rs | 223 +------------------ 3 files changed, 328 insertions(+), 218 deletions(-) create mode 100644 cesr/src/serder/event_strategies.rs diff --git a/cesr/src/serder/event_strategies.rs b/cesr/src/serder/event_strategies.rs new file mode 100644 index 0000000..8f898fb --- /dev/null +++ b/cesr/src/serder/event_strategies.rs @@ -0,0 +1,319 @@ +//! Shared proptest strategies over the builder-reachable KERI event space. +//! +//! Single source of truth for cross-backend (write path) and +//! strict-vs-reference (read path) differential property tests. + +use crate::core::matter::builder::MatterBuilder; +use crate::core::matter::code::{DigestCode, VerKeyCode}; +use crate::core::primitives::{Prefixer, Saider, Seqner, Tholder}; +use crate::keri::{ConfigTrait, Identifier, InceptionEvent, InteractionEvent, RotationEvent, Seal}; +#[allow( + unused_imports, + reason = "alloc prelude items; subset used per cfg/feature combination" +)] +use alloc::{ + borrow::Cow, borrow::ToOwned, format, string::String, string::ToString, vec, vec::Vec, +}; +use proptest::prelude::*; + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn prefixer(raw: [u8; 32]) -> Prefixer<'static> { + MatterBuilder::new() + .with_code(VerKeyCode::Ed25519) + .with_raw(Cow::<[u8]>::Owned(raw.to_vec())) + .unwrap() + .build() + .unwrap() +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn saider(raw: [u8; 32]) -> Saider<'static> { + MatterBuilder::new() + .with_code(DigestCode::Blake3_256) + .with_raw(Cow::<[u8]>::Owned(raw.to_vec())) + .unwrap() + .build() + .unwrap() +} + +// Strategies emit plain-data specs (all `Debug`) and the test bodies +// build domain events from them — the event types deliberately do not +// implement `Debug`. + +/// (basic?, raw) -> Identifier +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) type IdSpec = (bool, [u8; 32]); +/// (variant selector, raw a, raw b, sn) -> Seal +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) type SealSpec = (u8, [u8; 32], [u8; 32], u128); +/// (simple?, simple value, weighted clauses) -> Tholder +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) type TholderSpec = (bool, u64, Vec>); +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) type IcpSpec = ( + IdSpec, + u128, + [u8; 32], + Vec<[u8; 32]>, + TholderSpec, + Vec<[u8; 32]>, + TholderSpec, + Vec<[u8; 32]>, + u32, + Vec, + Vec, +); +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) type RotSpec = ( + IdSpec, + u128, + [u8; 32], + [u8; 32], + Vec<[u8; 32]>, + TholderSpec, + Vec<[u8; 32]>, + TholderSpec, + Vec<[u8; 32]>, + u32, + Vec, +); +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) type IxnSpec = (IdSpec, u128, [u8; 32], [u8; 32], Vec); + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn build_identifier((basic, raw): IdSpec) -> Identifier<'static> { + if basic { + Identifier::Basic(prefixer(raw)) + } else { + Identifier::SelfAddressing(saider(raw)) + } +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn build_seal((variant, a, b, sn): SealSpec) -> Seal { + match variant { + 0 => Seal::Digest { d: saider(a) }, + 1 => Seal::Root { rd: saider(a) }, + 2 => Seal::Source { + s: Seqner::new(sn), + d: saider(a), + }, + 3 => Seal::Event { + i: prefixer(b), + s: Seqner::new(sn), + d: saider(a), + }, + _ => Seal::Last { i: prefixer(a) }, + } +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn build_tholder((simple, value, clauses): TholderSpec) -> Tholder { + if simple { + Tholder::Simple(value) + } else { + Tholder::Weighted(clauses) + } +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn build_config(picks: &[bool]) -> Vec { + picks + .iter() + .map(|p| { + if *p { + ConfigTrait::EstOnly + } else { + ConfigTrait::DoNotDelegate + } + }) + .collect() +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn build_icp(spec: IcpSpec) -> InceptionEvent { + let (prefix, sn, said, keys, kt, next, nt, wits, bt, config, anchors) = spec; + InceptionEvent::new( + build_identifier(prefix), + Seqner::new(sn), + saider(said), + keys.into_iter().map(prefixer).collect(), + build_tholder(kt), + next.into_iter().map(saider).collect(), + build_tholder(nt), + wits.into_iter().map(prefixer).collect(), + bt, + build_config(&config), + anchors.into_iter().map(build_seal).collect(), + ) +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn build_rot(spec: RotSpec) -> RotationEvent { + let (prefix, sn, said, prior, keys, kt, next, nt, wits, bt, anchors) = spec; + RotationEvent::new( + build_identifier(prefix), + Seqner::new(sn), + saider(said), + saider(prior), + keys.into_iter().map(prefixer).collect(), + build_tholder(kt), + next.into_iter().map(saider).collect(), + build_tholder(nt), + wits.clone().into_iter().map(prefixer).collect(), + wits.into_iter().map(prefixer).collect(), + bt, + vec![], + anchors.into_iter().map(build_seal).collect(), + ) +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn build_ixn(spec: IxnSpec) -> InteractionEvent { + let (prefix, sn, said, prior, anchors) = spec; + InteractionEvent::new( + build_identifier(prefix), + Seqner::new(sn), + saider(said), + saider(prior), + anchors.into_iter().map(build_seal).collect(), + ) +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn sn_strategy() -> impl Strategy { + prop_oneof![Just(0_u128), Just(1_u128), Just(u128::MAX), any::()] +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn bt_strategy() -> impl Strategy { + prop_oneof![Just(0_u32), Just(1_u32), Just(u32::MAX), any::()] +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn tholder_strategy() -> impl Strategy { + ( + any::(), + prop_oneof![Just(0_u64), Just(1_u64), Just(u64::MAX), any::()], + proptest::collection::vec( + proptest::collection::vec((0_u64..=3, 0_u64..=3), 0..4), + 0..4, + ), + ) +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn seal_strategy() -> impl Strategy { + (0_u8..5, any::<[u8; 32]>(), any::<[u8; 32]>(), sn_strategy()) +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn icp_strategy() -> impl Strategy { + ( + any::(), + sn_strategy(), + any::<[u8; 32]>(), + proptest::collection::vec(any::<[u8; 32]>(), 0..3), + tholder_strategy(), + proptest::collection::vec(any::<[u8; 32]>(), 0..3), + tholder_strategy(), + proptest::collection::vec(any::<[u8; 32]>(), 0..3), + bt_strategy(), + proptest::collection::vec(any::(), 0..3), + proptest::collection::vec(seal_strategy(), 0..3), + ) +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn rot_strategy() -> impl Strategy { + ( + any::(), + sn_strategy(), + any::<[u8; 32]>(), + any::<[u8; 32]>(), + proptest::collection::vec(any::<[u8; 32]>(), 0..3), + tholder_strategy(), + proptest::collection::vec(any::<[u8; 32]>(), 0..3), + tholder_strategy(), + proptest::collection::vec(any::<[u8; 32]>(), 0..3), + bt_strategy(), + proptest::collection::vec(seal_strategy(), 0..3), + ) +} + +#[allow( + clippy::redundant_pub_crate, + reason = "pub(crate) is intentional — the enclosing module is crate-internal and `unreachable_pub` denies plain `pub`" +)] +pub(crate) fn ixn_strategy() -> impl Strategy { + ( + any::(), + sn_strategy(), + any::<[u8; 32]>(), + any::<[u8; 32]>(), + proptest::collection::vec(seal_strategy(), 0..4), + ) +} diff --git a/cesr/src/serder/mod.rs b/cesr/src/serder/mod.rs index 44581b6..ee9f949 100644 --- a/cesr/src/serder/mod.rs +++ b/cesr/src/serder/mod.rs @@ -21,6 +21,10 @@ pub mod builder; pub mod deserialize; /// Error types for serialization, deserialization, and SAID operations. pub mod error; +/// Shared proptest strategies over the builder-reachable KERI event space, +/// reused by the write-path and read-path differential property tests. +#[cfg(test)] +pub(crate) mod event_strategies; /// Primitive-to-string conversion helpers. pub mod primitives; /// SAID (Self-Addressing IDentifier) computation. diff --git a/cesr/src/serder/serialize/direct.rs b/cesr/src/serder/serialize/direct.rs index 432ad76..ca6505a 100644 --- a/cesr/src/serder/serialize/direct.rs +++ b/cesr/src/serder/serialize/direct.rs @@ -333,32 +333,15 @@ fn write_seal_array(buf: &mut Vec, seals: &[Seal]) { mod tests { use super::super::{SerdeJson, serialize_with}; use super::*; - use crate::core::matter::builder::MatterBuilder; - use crate::core::matter::code::{DigestCode, VerKeyCode}; - use crate::core::primitives::{Prefixer, Saider, Seqner}; + use crate::core::primitives::Seqner; use crate::keri::{DelegatedInceptionEvent, DelegatedRotationEvent, Identifier}; use crate::serder::deserialize::deserialize_inception; - use alloc::borrow::Cow; + use crate::serder::event_strategies::{ + IdSpec, build_icp, build_identifier, build_ixn, build_rot, icp_strategy, ixn_strategy, + prefixer, rot_strategy, saider, + }; use proptest::prelude::*; - fn prefixer(raw: [u8; 32]) -> Prefixer<'static> { - MatterBuilder::new() - .with_code(VerKeyCode::Ed25519) - .with_raw(Cow::<[u8]>::Owned(raw.to_vec())) - .unwrap() - .build() - .unwrap() - } - - fn saider(raw: [u8; 32]) -> Saider<'static> { - MatterBuilder::new() - .with_code(DigestCode::Blake3_256) - .with_raw(Cow::<[u8]>::Owned(raw.to_vec())) - .unwrap() - .build() - .unwrap() - } - fn assert_backends_identical(event: EventRef<'_>) { let reference = serialize_with(&SerdeJson, event).unwrap(); let direct = serialize_with(&DirectJson, event).unwrap(); @@ -374,202 +357,6 @@ mod tests { assert_eq!(reference.size(), direct.size()); } - // Strategies emit plain-data specs (all `Debug`) and the test bodies - // build domain events from them — the event types deliberately do not - // implement `Debug`. - - /// (basic?, raw) -> Identifier - type IdSpec = (bool, [u8; 32]); - /// (variant selector, raw a, raw b, sn) -> Seal - type SealSpec = (u8, [u8; 32], [u8; 32], u128); - /// (simple?, simple value, weighted clauses) -> Tholder - type TholderSpec = (bool, u64, Vec>); - type IcpSpec = ( - IdSpec, - u128, - [u8; 32], - Vec<[u8; 32]>, - TholderSpec, - Vec<[u8; 32]>, - TholderSpec, - Vec<[u8; 32]>, - u32, - Vec, - Vec, - ); - type RotSpec = ( - IdSpec, - u128, - [u8; 32], - [u8; 32], - Vec<[u8; 32]>, - TholderSpec, - Vec<[u8; 32]>, - TholderSpec, - Vec<[u8; 32]>, - u32, - Vec, - ); - type IxnSpec = (IdSpec, u128, [u8; 32], [u8; 32], Vec); - - fn build_identifier((basic, raw): IdSpec) -> Identifier<'static> { - if basic { - Identifier::Basic(prefixer(raw)) - } else { - Identifier::SelfAddressing(saider(raw)) - } - } - - fn build_seal((variant, a, b, sn): SealSpec) -> Seal { - match variant { - 0 => Seal::Digest { d: saider(a) }, - 1 => Seal::Root { rd: saider(a) }, - 2 => Seal::Source { - s: Seqner::new(sn), - d: saider(a), - }, - 3 => Seal::Event { - i: prefixer(b), - s: Seqner::new(sn), - d: saider(a), - }, - _ => Seal::Last { i: prefixer(a) }, - } - } - - fn build_tholder((simple, value, clauses): TholderSpec) -> Tholder { - if simple { - Tholder::Simple(value) - } else { - Tholder::Weighted(clauses) - } - } - - fn build_config(picks: &[bool]) -> Vec { - picks - .iter() - .map(|p| { - if *p { - ConfigTrait::EstOnly - } else { - ConfigTrait::DoNotDelegate - } - }) - .collect() - } - - fn build_icp(spec: IcpSpec) -> InceptionEvent { - let (prefix, sn, said, keys, kt, next, nt, wits, bt, config, anchors) = spec; - InceptionEvent::new( - build_identifier(prefix), - Seqner::new(sn), - saider(said), - keys.into_iter().map(prefixer).collect(), - build_tholder(kt), - next.into_iter().map(saider).collect(), - build_tholder(nt), - wits.into_iter().map(prefixer).collect(), - bt, - build_config(&config), - anchors.into_iter().map(build_seal).collect(), - ) - } - - fn build_rot(spec: RotSpec) -> RotationEvent { - let (prefix, sn, said, prior, keys, kt, next, nt, wits, bt, anchors) = spec; - RotationEvent::new( - build_identifier(prefix), - Seqner::new(sn), - saider(said), - saider(prior), - keys.into_iter().map(prefixer).collect(), - build_tholder(kt), - next.into_iter().map(saider).collect(), - build_tholder(nt), - wits.clone().into_iter().map(prefixer).collect(), - wits.into_iter().map(prefixer).collect(), - bt, - vec![], - anchors.into_iter().map(build_seal).collect(), - ) - } - - fn build_ixn(spec: IxnSpec) -> InteractionEvent { - let (prefix, sn, said, prior, anchors) = spec; - InteractionEvent::new( - build_identifier(prefix), - Seqner::new(sn), - saider(said), - saider(prior), - anchors.into_iter().map(build_seal).collect(), - ) - } - - fn sn_strategy() -> impl Strategy { - prop_oneof![Just(0_u128), Just(1_u128), Just(u128::MAX), any::()] - } - - fn bt_strategy() -> impl Strategy { - prop_oneof![Just(0_u32), Just(1_u32), Just(u32::MAX), any::()] - } - - fn tholder_strategy() -> impl Strategy { - ( - any::(), - prop_oneof![Just(0_u64), Just(1_u64), Just(u64::MAX), any::()], - proptest::collection::vec( - proptest::collection::vec((0_u64..=3, 0_u64..=3), 0..4), - 0..4, - ), - ) - } - - fn seal_strategy() -> impl Strategy { - (0_u8..5, any::<[u8; 32]>(), any::<[u8; 32]>(), sn_strategy()) - } - - fn icp_strategy() -> impl Strategy { - ( - any::(), - sn_strategy(), - any::<[u8; 32]>(), - proptest::collection::vec(any::<[u8; 32]>(), 0..3), - tholder_strategy(), - proptest::collection::vec(any::<[u8; 32]>(), 0..3), - tholder_strategy(), - proptest::collection::vec(any::<[u8; 32]>(), 0..3), - bt_strategy(), - proptest::collection::vec(any::(), 0..3), - proptest::collection::vec(seal_strategy(), 0..3), - ) - } - - fn rot_strategy() -> impl Strategy { - ( - any::(), - sn_strategy(), - any::<[u8; 32]>(), - any::<[u8; 32]>(), - proptest::collection::vec(any::<[u8; 32]>(), 0..3), - tholder_strategy(), - proptest::collection::vec(any::<[u8; 32]>(), 0..3), - tholder_strategy(), - proptest::collection::vec(any::<[u8; 32]>(), 0..3), - bt_strategy(), - proptest::collection::vec(seal_strategy(), 0..3), - ) - } - - fn ixn_strategy() -> impl Strategy { - ( - any::(), - sn_strategy(), - any::<[u8; 32]>(), - any::<[u8; 32]>(), - proptest::collection::vec(seal_strategy(), 0..4), - ) - } - proptest! { #![proptest_config(ProptestConfig::with_cases(64))] From 7edd7253cf5fc2ef5f2165db0d002744b3c7bdbe Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Fri, 10 Jul 2026 23:42:52 +0200 Subject: [PATCH 13/21] test(serder): strict-vs-reference read-path differential and mutation-subset properties (#142) Co-Authored-By: Claude Fable 5 --- cesr/src/serder/deserialize.rs | 173 ++++++++++++++++++++++++++++++++- 1 file changed, 169 insertions(+), 4 deletions(-) diff --git a/cesr/src/serder/deserialize.rs b/cesr/src/serder/deserialize.rs index 77ad76b..2a304ef 100644 --- a/cesr/src/serder/deserialize.rs +++ b/cesr/src/serder/deserialize.rs @@ -1236,10 +1236,13 @@ mod tests { fn deserialize_inception_rejects_dip_bytes() { let dip = DelegatedInceptionEvent::new(probe_icp(), make_prefixer().into()); let raw = serialize_delegated_inception(&dip).unwrap(); - assert!( - deserialize_inception(raw.as_bytes()).is_err(), - "dip bytes must not silently deserialize as icp (delegator dropped)" - ); + assert!(matches!( + deserialize_inception(raw.as_bytes()), + Err(SerderError::NonCanonical { + expected: "icp", + .. + }) + )); } // ----------------------------------------------------------------------- @@ -1285,4 +1288,166 @@ mod tests { "expected UnparseablePrimitive, got {err:?}" ); } + + // ----------------------------------------------------------------------- + // Overflow boundaries: the conversion layer between parsed decimal/hex + // text and fixed-width integers must reject overflow as a typed error, + // never wrap or saturate. + // ----------------------------------------------------------------------- + + #[test] + fn tholder_number_overflow_is_invalid_primitive() { + let over_u64 = "18446744073709551616"; // u64::MAX + 1 + assert!(matches!( + tholder_from_parsed(&ParsedTholder::Number(over_u64)), + Err(SerderError::InvalidPrimitive { field: "kt", .. }) + )); + let max_u64 = "18446744073709551615"; + assert!(matches!( + tholder_from_parsed(&ParsedTholder::Number(max_u64)), + Ok(Tholder::Simple(u64::MAX)) + )); + } + + #[test] + fn witness_threshold_overflow_is_invalid_primitive() { + assert!(matches!( + witness_threshold_from_parsed(&ParsedCount::Number("4294967296")), // u32::MAX + 1 + Err(SerderError::InvalidPrimitive { field: "bt", .. }) + )); + assert_eq!( + witness_threshold_from_parsed(&ParsedCount::Number("4294967295")).unwrap(), + u32::MAX + ); + assert!(matches!( + witness_threshold_from_parsed(&ParsedCount::Number( + "340282366920938463463374607431768211456" + )), // u128::MAX + 1 + Err(SerderError::InvalidPrimitive { field: "bt", .. }) + )); + assert!(matches!( + witness_threshold_from_parsed(&ParsedCount::Hex("100000000")), // > u32::MAX in hex + Err(SerderError::InvalidPrimitive { field: "bt", .. }) + )); + } + + // ----------------------------------------------------------------------- + // Differential: strict canonical parser vs. the pre-#142 tolerant oracle + // ----------------------------------------------------------------------- + + mod differential { + use super::super::reference; + use super::*; + use crate::serder::event_strategies::{ + IdSpec, TholderSpec, build_icp, build_identifier, build_ixn, build_rot, icp_strategy, + ixn_strategy, rot_strategy, + }; + use crate::serder::serialize::{EventRef, SerdeJson, serialize_with}; + use proptest::prelude::*; + + /// The reference oracle is the single source of validity truth: a + /// `TholderSpec` is only usable in a differential test if the oracle + /// itself would accept the weighted clauses it produces (rejects + /// zero-denominator fractions via `parse_weight`, shared by both + /// parsers). Filtering here keeps that rule in one place instead of + /// duplicating `parse_weight`'s validity logic in the strategy layer. + fn has_valid_weights(spec: &TholderSpec) -> bool { + let (simple, _, clauses) = spec; + *simple || clauses.iter().flatten().all(|(_, den)| *den != 0) + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + #[test] + fn icp_strict_equals_reference(spec in icp_strategy()) { + prop_assume!(has_valid_weights(&spec.4) && has_valid_weights(&spec.6)); + let event = build_icp(spec); + let bytes = serialize_with(&SerdeJson, EventRef::Inception(&event)).unwrap(); + let strict = deserialize_inception(bytes.as_bytes()).unwrap(); + let oracle = reference::deserialize_inception(bytes.as_bytes()).unwrap(); + let strict_bytes = serialize_inception(&strict).unwrap(); + let oracle_bytes = serialize_inception(&oracle).unwrap(); + prop_assert_eq!(strict_bytes.as_bytes(), oracle_bytes.as_bytes()); + prop_assert_eq!(strict_bytes.as_bytes(), bytes.as_bytes()); + } + + #[test] + fn rot_strict_equals_reference(spec in rot_strategy()) { + prop_assume!(has_valid_weights(&spec.5) && has_valid_weights(&spec.7)); + let event = build_rot(spec); + let bytes = serialize_with(&SerdeJson, EventRef::Rotation(&event)).unwrap(); + let strict = deserialize_rotation(bytes.as_bytes()).unwrap(); + let oracle = reference::deserialize_rotation(bytes.as_bytes()).unwrap(); + let strict_bytes = serialize_rotation(&strict).unwrap(); + let oracle_bytes = serialize_rotation(&oracle).unwrap(); + prop_assert_eq!(strict_bytes.as_bytes(), oracle_bytes.as_bytes()); + prop_assert_eq!(strict_bytes.as_bytes(), bytes.as_bytes()); + } + + #[test] + fn ixn_strict_equals_reference(spec in ixn_strategy()) { + let event = build_ixn(spec); + let bytes = serialize_with(&SerdeJson, EventRef::Interaction(&event)).unwrap(); + let strict = deserialize_interaction(bytes.as_bytes()).unwrap(); + let oracle = reference::deserialize_interaction(bytes.as_bytes()).unwrap(); + let strict_bytes = serialize_interaction(&strict).unwrap(); + let oracle_bytes = serialize_interaction(&oracle).unwrap(); + prop_assert_eq!(strict_bytes.as_bytes(), oracle_bytes.as_bytes()); + prop_assert_eq!(strict_bytes.as_bytes(), bytes.as_bytes()); + } + + #[test] + fn dip_strict_equals_reference(spec in icp_strategy(), delegator in any::()) { + prop_assume!(has_valid_weights(&spec.4) && has_valid_weights(&spec.6)); + let dip = DelegatedInceptionEvent::new(build_icp(spec), build_identifier(delegator)); + let bytes = serialize_with(&SerdeJson, EventRef::DelegatedInception(&dip)).unwrap(); + let strict = deserialize_delegated_inception(bytes.as_bytes()).unwrap(); + let oracle = reference::deserialize_delegated_inception(bytes.as_bytes()).unwrap(); + let strict_bytes = serialize_delegated_inception(&strict).unwrap(); + let oracle_bytes = serialize_delegated_inception(&oracle).unwrap(); + prop_assert_eq!(strict_bytes.as_bytes(), oracle_bytes.as_bytes()); + prop_assert_eq!(strict_bytes.as_bytes(), bytes.as_bytes()); + } + + #[test] + fn drt_strict_equals_reference(spec in rot_strategy()) { + prop_assume!(has_valid_weights(&spec.5) && has_valid_weights(&spec.7)); + let drt = DelegatedRotationEvent::new(build_rot(spec)); + let bytes = serialize_with(&SerdeJson, EventRef::DelegatedRotation(&drt)).unwrap(); + let strict = deserialize_delegated_rotation(bytes.as_bytes()).unwrap(); + let oracle = reference::deserialize_delegated_rotation(bytes.as_bytes()).unwrap(); + let strict_bytes = serialize_delegated_rotation(&strict).unwrap(); + let oracle_bytes = serialize_delegated_rotation(&oracle).unwrap(); + prop_assert_eq!(strict_bytes.as_bytes(), oracle_bytes.as_bytes()); + prop_assert_eq!(strict_bytes.as_bytes(), bytes.as_bytes()); + } + + /// Strict acceptance is a subset of tolerant acceptance: any + /// single-byte mutation the strict parser accepts, the reference + /// oracle must also accept — and both must see the same event. + #[test] + fn strict_acceptance_is_subset_of_reference( + spec in ixn_strategy(), + idx in any::(), + byte in any::(), + ) { + let event = build_ixn(spec); + let bytes = serialize_with(&SerdeJson, EventRef::Interaction(&event)).unwrap(); + let mut mutated = bytes.as_bytes().to_vec(); + let i = idx.index(mutated.len()); + mutated[i] = byte; + if let Ok(strict) = deserialize_interaction(&mutated) { + let oracle = reference::deserialize_interaction(&mutated); + prop_assert!( + oracle.is_ok(), + "strict accepted a mutation the tolerant oracle rejects" + ); + let strict_bytes = serialize_interaction(&strict).unwrap(); + let oracle_bytes = serialize_interaction(&oracle.unwrap()).unwrap(); + prop_assert_eq!(strict_bytes.as_bytes(), oracle_bytes.as_bytes()); + } + } + } + } } From d2e14b12103f834e854c46946e915161768356ca Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Fri, 10 Jul 2026 23:51:15 +0200 Subject: [PATCH 14/21] test(serder): pin strict read-path allocation count (#142) Co-Authored-By: Claude Fable 5 --- cesr/tests/serder_allocation.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/cesr/tests/serder_allocation.rs b/cesr/tests/serder_allocation.rs index 14c497f..b6e2409 100644 --- a/cesr/tests/serder_allocation.rs +++ b/cesr/tests/serder_allocation.rs @@ -23,7 +23,7 @@ use cesr::core::matter::builder::MatterBuilder; use cesr::core::matter::code::{DigestCode, VerKeyCode}; use cesr::core::primitives::{Prefixer, Saider, Seqner, Tholder}; use cesr::keri::{ConfigTrait, Identifier, InceptionEvent, Seal}; -use cesr::serder::{DirectJson, EventRef, SerdeJson, serialize_with}; +use cesr::serder::{DirectJson, EventRef, SerdeJson, deserialize_event, serialize_with}; use core::cell::Cell; use std::alloc::{GlobalAlloc, Layout, System}; @@ -127,3 +127,31 @@ fn direct_backend_allocates_strictly_less_than_serde_json() { reintroduced an intermediate tree or render" ); } + +/// Exact allocation count for deserializing `fixture_icp`'s event: one raw +/// scratch copy for SAID verification plus the parsed domain-type +/// construction (Vecs of keys/digests/witnesses/seals, qb64 raw buffers, +/// error-free paths only). Deterministic for a fixed fixture; a change means +/// the read path's allocation shape changed — re-derive deliberately, don't +/// just bump the number. +const DESERIALIZE_ALLOCS: usize = 38; + +#[test] +fn deserialize_allocation_count_is_pinned() { + let event = fixture_icp(); + let serialized = + serialize_with(&DirectJson, EventRef::Inception(&event)).expect("fixture serializes"); + let bytes = serialized.as_bytes(); + + let _ = deserialize_event(bytes).expect("fixture deserializes"); + + let (parsed, allocs) = measure(|| deserialize_event(bytes).expect("fixture deserializes")); + drop(parsed); + + assert_eq!( + allocs, DESERIALIZE_ALLOCS, + "deserialize_event allocation count changed — the strict read path \ + must stay at one scratch copy plus domain-type construction; a rise \ + means an intermediate tree or render crept back in" + ); +} From b3e1a43cf9f0b5055c3f5467eb9375aecd553251 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Fri, 10 Jul 2026 23:52:32 +0200 Subject: [PATCH 15/21] bench(serder): deserialize_event throughput for CodSpeed (#142) Co-Authored-By: Claude Fable 5 --- cesr/benches/serder.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/cesr/benches/serder.rs b/cesr/benches/serder.rs index 4c14989..f070824 100644 --- a/cesr/benches/serder.rs +++ b/cesr/benches/serder.rs @@ -20,7 +20,7 @@ use cesr::core::matter::builder::MatterBuilder; use cesr::core::matter::code::{DigestCode, VerKeyCode}; use cesr::core::primitives::{Prefixer, Saider, Seqner, Tholder}; use cesr::keri::{ConfigTrait, Identifier, InceptionEvent, InteractionEvent, Seal}; -use cesr::serder::{DirectJson, EventRef, SerdeJson, serialize_with}; +use cesr::serder::{DirectJson, EventRef, SerdeJson, deserialize_event, serialize_with}; use core::hint::black_box; use criterion::{Criterion, criterion_group, criterion_main}; @@ -104,5 +104,19 @@ fn bench_serialize(c: &mut Criterion) { group.finish(); } -criterion_group!(benches, bench_serialize); +fn bench_deserialize(c: &mut Criterion) { + let icp = fixture_icp(); + let Ok(serialized) = serialize_with(&SerdeJson, EventRef::Inception(&icp)) else { + unreachable!("fixture_icp always serializes") + }; + let bytes = serialized.as_bytes(); + + let mut group = c.benchmark_group("serder_deserialize"); + group.bench_function("icp", |b| { + b.iter(|| deserialize_event(black_box(bytes))); + }); + group.finish(); +} + +criterion_group!(benches, bench_serialize, bench_deserialize); criterion_main!(benches); From 5aa206d020ce43cca8636d296919fff60951e1ef Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Fri, 10 Jul 2026 23:57:47 +0200 Subject: [PATCH 16/21] ci(fuzz): strict serder deserialize fuzz target for both engines (#142) Co-Authored-By: Claude Fable 5 --- .github/workflows/fuzz.yml | 2 + fuzz-afl/Cargo.lock | 210 ++++++++++++++++++- fuzz-afl/Cargo.toml | 4 + fuzz-afl/src/bin/serder_deserialize_event.rs | 3 + fuzz-common/Cargo.lock | 210 ++++++++++++++++++- fuzz-common/Cargo.toml | 2 +- fuzz-common/src/lib.rs | 23 ++ fuzz/Cargo.lock | 188 ++++++++++++++++- fuzz/tests/serder.rs | 6 + 9 files changed, 638 insertions(+), 10 deletions(-) create mode 100644 fuzz-afl/src/bin/serder_deserialize_event.rs create mode 100644 fuzz/tests/serder.rs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 8a97083..23ae114 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -52,6 +52,7 @@ jobs: - stream_parse_version_string - stream_parse_version_string_v2 - qb64_qb2_roundtrip + - serder_deserialize_event steps: - name: Checkout repository uses: actions/checkout@v4 @@ -167,6 +168,7 @@ jobs: - stream_parse_version_string - stream_parse_version_string_v2 - qb64_qb2_roundtrip + - serder_deserialize_event env: # AFL++ refuses to run under a locked-down CI sandbox without these. AFL_SKIP_CPUFREQ: "1" diff --git a/fuzz-afl/Cargo.lock b/fuzz-afl/Cargo.lock index 0b2a15e..f2b229b 100644 --- a/fuzz-afl/Cargo.lock +++ b/fuzz-afl/Cargo.lock @@ -14,6 +14,18 @@ dependencies = [ "xdg", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "autocfg" version = "1.5.1" @@ -38,6 +50,29 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -47,12 +82,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cesr-fuzz-afl" version = "0.0.0" @@ -63,17 +114,24 @@ dependencies = [ [[package]] name = "cesr-rs" -version = "0.4.0" +version = "0.5.0" dependencies = [ "base64", + "blake2", + "blake3", "bytes", + "digest", "ed25519-dalek", + "getrandom", "k256", "num-traits", "p256", "rand_core", "serde", "serde_json", + "sha2", + "sha3", + "signature", "strum", "thiserror", "zeroize", @@ -91,6 +149,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -100,6 +164,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -129,7 +202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -257,6 +330,36 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "fuzz-common" version = "0.0.0" @@ -282,8 +385,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -343,6 +448,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "k256" version = "0.13.4" @@ -356,6 +472,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "libc" version = "0.2.186" @@ -438,6 +563,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkcs8" version = "0.10.2" @@ -503,6 +634,12 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "sec1" version = "0.7.3" @@ -574,10 +711,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ "digest", + "keccak", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signature" version = "2.2.0" @@ -594,6 +747,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "spki" version = "0.7.3" @@ -687,6 +846,51 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/fuzz-afl/Cargo.toml b/fuzz-afl/Cargo.toml index 9b1cfe4..e702d26 100644 --- a/fuzz-afl/Cargo.toml +++ b/fuzz-afl/Cargo.toml @@ -64,5 +64,9 @@ path = "src/bin/stream_parse_version_string_v2.rs" name = "qb64_qb2_roundtrip" path = "src/bin/qb64_qb2_roundtrip.rs" +[[bin]] +name = "serder_deserialize_event" +path = "src/bin/serder_deserialize_event.rs" + [profile.release] panic = "abort" diff --git a/fuzz-afl/src/bin/serder_deserialize_event.rs b/fuzz-afl/src/bin/serder_deserialize_event.rs new file mode 100644 index 0000000..bd1f3a7 --- /dev/null +++ b/fuzz-afl/src/bin/serder_deserialize_event.rs @@ -0,0 +1,3 @@ +fn main() { + afl::fuzz!(|data: &[u8]| fuzz_common::serder_deserialize_event(data)); +} diff --git a/fuzz-common/Cargo.lock b/fuzz-common/Cargo.lock index 91618b5..382fd37 100644 --- a/fuzz-common/Cargo.lock +++ b/fuzz-common/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "autocfg" version = "1.5.1" @@ -26,6 +38,29 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -35,25 +70,48 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cesr-rs" -version = "0.4.0" +version = "0.5.0" dependencies = [ "base64", + "blake2", + "blake3", "bytes", + "digest", "ed25519-dalek", + "getrandom", "k256", "num-traits", "p256", "rand_core", "serde", "serde_json", + "sha2", + "sha3", + "signature", "strum", "thiserror", "zeroize", @@ -71,6 +129,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -80,6 +144,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -109,7 +182,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -237,6 +310,36 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "fuzz-common" version = "0.0.0" @@ -262,8 +365,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -314,6 +419,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "k256" version = "0.13.4" @@ -327,6 +443,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "libc" version = "0.2.186" @@ -409,6 +534,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkcs8" version = "0.10.2" @@ -474,6 +605,12 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "sec1" version = "0.7.3" @@ -545,10 +682,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ "digest", + "keccak", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signature" version = "2.2.0" @@ -565,6 +718,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "spki" version = "0.7.3" @@ -658,6 +817,51 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + [[package]] name = "zeroize" version = "1.9.0" diff --git a/fuzz-common/Cargo.toml b/fuzz-common/Cargo.toml index 656a4b7..141837e 100644 --- a/fuzz-common/Cargo.toml +++ b/fuzz-common/Cargo.toml @@ -13,4 +13,4 @@ publish = false license = "MIT OR Apache-2.0" [dependencies] -cesr = { package = "cesr-rs", path = "../cesr", features = ["stream"] } +cesr = { package = "cesr-rs", path = "../cesr", features = ["stream", "serder"] } diff --git a/fuzz-common/src/lib.rs b/fuzz-common/src/lib.rs index 9d78e57..4803830 100644 --- a/fuzz-common/src/lib.rs +++ b/fuzz-common/src/lib.rs @@ -8,6 +8,7 @@ use cesr::core::indexer::IndexerBuilder; use cesr::core::matter::builder::MatterBuilder; +use cesr::serder::{KeriSerialize, deserialize_event}; use cesr::stream::{ groups, groups_v2, parse_group, parse_group_v2, parse_message, parse_version_string, parse_version_string_v2, qb2_to_qb64, qb64_to_qb2, @@ -61,6 +62,27 @@ pub fn stream_parse_version_string_v2(data: &[u8]) { let _ = parse_version_string_v2(data); } +/// Idempotence oracle for the strict canonical KERI event deserializer: if +/// `data` parses, its re-serialization must also parse. +/// +/// Deliberately does NOT assert byte-identity between `data` and the +/// re-serialized bytes. keripy-native integers (e.g. `"bt":0`) legally +/// re-render as hex strings (`"bt":"0"`) on serialize, per keripy's +/// intive/hex number rendering — so accepted-input bytes and re-serialized +/// bytes may differ even though both are valid encodings of the same event. +/// The invariant that must hold is parse -> serialize -> parse succeeding, +/// not byte-for-byte stability. +pub fn serder_deserialize_event(data: &[u8]) { + if let Ok(event) = deserialize_event(data) { + let Ok(reser) = event.serialize() else { + panic!("a strictly-parsed event must re-serialize"); + }; + if deserialize_event(reser.as_bytes()).is_err() { + panic!("a re-serialized event must re-parse"); + } + } +} + pub fn qb64_qb2_roundtrip(data: &[u8]) { let Ok(qb2) = qb64_to_qb2(data) else { return; @@ -96,5 +118,6 @@ mod tests { stream_parse_version_string(&[]); stream_parse_version_string_v2(&[]); qb64_qb2_roundtrip(&[]); + serder_deserialize_event(&[]); } } diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 93f31a1..e6f9342 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -8,6 +8,18 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "autocfg" version = "1.5.1" @@ -32,6 +44,29 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -134,6 +169,12 @@ dependencies = [ "cc", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.0" @@ -162,17 +203,24 @@ dependencies = [ [[package]] name = "cesr-rs" -version = "0.4.0" +version = "0.5.0" dependencies = [ "base64", + "blake2", + "blake3", "bytes", + "digest", "ed25519-dalek", + "getrandom 0.2.17", "k256", "num-traits", "p256", "rand_core 0.6.4", "serde", "serde_json", + "sha2", + "sha3", + "signature", "strum", "thiserror", "zeroize", @@ -190,6 +238,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -199,6 +253,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -228,7 +291,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -368,6 +431,30 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "fuzz-common" version = "0.0.0" @@ -393,8 +480,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -457,6 +546,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "k256" version = "0.13.4" @@ -470,6 +570,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -558,6 +667,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkcs8" version = "0.10.2" @@ -692,6 +807,12 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "sec1" version = "0.7.3" @@ -763,10 +884,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "shlex" version = "2.0.1" @@ -789,6 +920,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "spki" version = "0.7.3" @@ -908,6 +1045,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winnow" version = "0.5.40" diff --git a/fuzz/tests/serder.rs b/fuzz/tests/serder.rs new file mode 100644 index 0000000..91b8832 --- /dev/null +++ b/fuzz/tests/serder.rs @@ -0,0 +1,6 @@ +//! Fuzz target for strict canonical KERI event deserialization. + +#[test] +fn serder_deserialize_event() { + bolero::check!().for_each(|input: &[u8]| fuzz_common::serder_deserialize_event(input)); +} From 9aae37c2e14a3f41a2cb5a6580b53157c71e1220 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sat, 11 Jul 2026 00:01:21 +0200 Subject: [PATCH 17/21] docs(serder): describe the strict canonical read path (#142) Co-Authored-By: Claude Fable 5 --- cesr/src/serder/deserialize.rs | 13 +++++++------ cesr/src/serder/mod.rs | 3 ++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/cesr/src/serder/deserialize.rs b/cesr/src/serder/deserialize.rs index 2a304ef..f3d3f7d 100644 --- a/cesr/src/serder/deserialize.rs +++ b/cesr/src/serder/deserialize.rs @@ -1,10 +1,11 @@ -//! KERI event deserialization from strict canonical JSON with SAID -//! verification. +//! KERI event deserialization from canonical JSON with SAID verification. //! -//! Parses the five fixed canonical event grammars via the single-pass -//! [`canonical`] parser, reconstructing [`crate::keri`] domain types. Every -//! deserialized event's SAID is verified in place over the raw bytes (span -//! fill + hash) before being returned. +//! The read path is a strict single-pass canonical parser +//! ([`canonical`]): compact JSON, spec field order, no escapes — any +//! deviation is a typed [`SerderError::NonCanonical`]. SAID verification +//! is offset-based: one scratch copy of the raw bytes, the `d` (and `i` +//! for `icp`/`dip`) spans overwritten with `#`, one hash — no +//! parse-mutate-re-render. use crate::core::matter::builder::MatterBuilder; use crate::core::matter::code::{DigestCode, MatterCode, VerKeyCode}; diff --git a/cesr/src/serder/mod.rs b/cesr/src/serder/mod.rs index ee9f949..9c25966 100644 --- a/cesr/src/serder/mod.rs +++ b/cesr/src/serder/mod.rs @@ -4,7 +4,8 @@ //! This crate serializes [`keri_core`] domain types to canonical JSON //! matching keripy's default wire format, computes Self-Addressing //! Identifier (SAID) digests, and deserializes JSON back into domain types -//! with SAID verification. +//! via a strict canonical parser with in-place (offset-based) SAID +//! verification. #[cfg(feature = "alloc")] #[allow( From 9fee41b97c575c02092c051ad7f9164da43135d0 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sat, 11 Jul 2026 00:01:23 +0200 Subject: [PATCH 18/21] docs(plan): #142 strict canonical read-path implementation plan Co-Authored-By: Claude Fable 5 --- ...26-07-10-142-strict-canonical-read-path.md | 2183 +++++++++++++++++ 1 file changed, 2183 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-142-strict-canonical-read-path.md diff --git a/docs/superpowers/plans/2026-07-10-142-strict-canonical-read-path.md b/docs/superpowers/plans/2026-07-10-142-strict-canonical-read-path.md new file mode 100644 index 0000000..460fb44 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-142-strict-canonical-read-path.md @@ -0,0 +1,2183 @@ +# #142 · Strict Canonical Read-Path Parser Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the tolerant `serde_json` deserialize path in `serder` with a strict single-pass canonical parser for the five fixed KERI event grammars, with offset-based SAID verification (copy-once + slot-fill + hash, no re-render). + +**Architecture:** A new `pub(crate)` module `serder::deserialize::canonical` holds a byte `Scanner` and per-ilk grammar functions that return borrowed (`&'a str`) field views plus the byte spans of the `d`/`i` SAID slots (the read-path mirror of `EventLayout`). `said.rs` gains `verify_said_spans` (scratch-copy, fill spans with `#`, hash, compare). The public `deserialize_*` entry points re-wire onto the strict parser; the old `serde_json::Value` path moves to `#[cfg(test)]` module `reference` and survives only as the differential oracle. Public `said::verify_said` is re-implemented strict and changes signature to `Result<(), SerderError>`. + +**Tech Stack:** Rust 2024 (stable 1.95.0), no new dependencies. `thiserror` error variant, `proptest` differentials, bolero + afl.rs fuzz targets, criterion/CodSpeed bench, counting-allocator pin. Gate: `nix flake check`. + +**Breaking changes (MINOR under 0.x, PR title `feat(serder)!:`):** +1. New `SerderError::NonCanonical { offset, expected, found }` variant; read-path errors change variant for non-canonical inputs. +2. `said::verify_said` becomes `Result<(), SerderError>` (was `Result`), strict. +3. Per-ilk deserializers now require their exact ilk (`deserialize_rotation` no longer accepts `drt` bytes; `deserialize_inception` no longer silently accepts `dip` bytes and drops the delegator). +4. Whitespace, duplicate keys, reordered fields, escapes, and trailing bytes are rejected by construction. + +**Conformance decisions (locked):** +- JSON **integers** remain accepted for `kt`/`nt`/`bt` (keripy `intive=True` parity — the current tolerant path accepts them and has behavior-pin tests). Grammar: `0|[1-9][0-9]*`, no leading zeros, no sign, no float/exponent. +- All other scalar values are strings; `\` (any escape), control chars < 0x20, and non-UTF-8 bytes inside strings are non-canonical. Rationale: every value class in the five grammars is qb64 / hex / ASCII constant — no canonical event ever requires escaping (design §2.3). +- keripy corpus differential is covered by `keri/tests/differential.rs`, which drives the corpus through the public `deserialize_event` (now strict) under `nix flake check`. No corpus duplication into cesr (respects #133 fixture-dedup). + +--- + +## Preflight + +- [ ] **Step 0.1: Create branch off up-to-date main** (use superpowers:using-git-worktrees if isolating) + +```bash +cd /Users/joel/Code/devrandom/cesr +git fetch origin && git checkout -b feat/142-strict-read-path origin/main +``` + +- [ ] **Step 0.2: Confirm no external callers of the internals being moved** + +Run: `rg -n "verify_said|validate_version_string|tholder_from_json|seal_from_json" --glob '!cesr/src/serder/**' cesr/ keri/ fuzz*/` +Expected: only hits inside `cesr/src/serder/` (deserialize.rs, said.rs). If `cesr/tests/frozen_surface.rs` pins `verify_said`, note it — Task 8 updates it. + +--- + +### Task 1: `NonCanonical` error variant + +**Files:** +- Modify: `cesr/src/serder/error.rs` + +- [ ] **Step 1.1: Add the variant** (after `UnparseablePrimitive`, before `VersionStringOverflow`) + +```rust + /// Input deviates from the fixed canonical event grammar at a specific + /// byte: whitespace, reordered/duplicate/unknown fields, string escapes, + /// or malformed framing. Canonical KERI event JSON is byte-deterministic, + /// so any deviation is rejected by construction. + #[error("non-canonical event JSON at byte {offset}: expected {expected}, found {found:?}")] + NonCanonical { + /// Byte offset in the raw input where the grammar was violated. + offset: usize, + /// What the grammar required at that offset. + expected: &'static str, + /// The byte actually found, or `None` at end of input. + found: Option, + }, +``` + +- [ ] **Step 1.2: Widen the `InvalidEventLayout` doc comment** (the read path reuses it for parser-reported slot inconsistencies) + +```rust + /// A serialization backend or the canonical parser reported a slot layout + /// inconsistent with the bytes it rendered or parsed — an internal bug, + /// surfaced as a typed error so a corrupt frame can never escape. + #[error("invalid event layout: {0}")] + InvalidEventLayout(&'static str), +``` + +- [ ] **Step 1.3: Build check** + +Run: `nix develop --command cargo build -p cesr-rs --features serder` +Expected: compiles (variant unused yet is fine — no dead-code lint on pub enum variants). + +- [ ] **Step 1.4: Commit** + +```bash +git add cesr/src/serder/error.rs +git commit -m "feat(serder): NonCanonical error variant for the strict read path (#142)" +``` + +--- + +### Task 2: Scanner core + +**Files:** +- Create: `cesr/src/serder/deserialize/canonical.rs` +- Modify: `cesr/src/serder/deserialize.rs` (add module decl) + +- [ ] **Step 2.1: Declare the submodule.** In `deserialize.rs`, after the existing `use` block: + +```rust +pub(crate) mod canonical; +``` + +- [ ] **Step 2.2: Write failing scanner tests.** Create `canonical.rs` with module doc, `Spanned`, `Scanner`, and tests first: + +```rust +//! Strict single-pass parser for the five fixed canonical KERI event +//! grammars (`icp`, `rot`, `ixn`, `dip`, `drt`). +//! +//! Canonical event JSON is byte-deterministic: compact (no whitespace), +//! spec field order, and values that never require string escaping (qb64, +//! hex, ASCII constants — design §2.3 of the #79 write-up). This parser +//! accepts exactly that language, plus JSON integers for `kt`/`nt`/`bt` +//! (keripy `intive=True` emits them; their SAIDs are computed over the +//! integer form, so rejecting them would be a conformance gap). +//! +//! Every field is returned as a borrowed `&str`; the `d` (and `i` for +//! `icp`/`dip`) value byte spans are reported so SAID verification can +//! copy the raw once, overwrite the spans with `#`, and hash — the +//! read-path mirror of the write path's `EventLayout` slots. + +#[cfg(feature = "alloc")] +#[allow( + unused_imports, + reason = "alloc prelude items; subset used per cfg/feature combination" +)] +use alloc::{borrow::ToOwned, format, string::String, vec, vec::Vec}; +use core::ops::Range; +use core::str; + +use crate::serder::error::SerderError; +use crate::serder::version::{SerKind, VERSION_STRING_LEN, VersionString}; + +/// A borrowed string value plus its byte span in the raw input. +pub(crate) struct Spanned<'a> { + pub(crate) value: &'a str, + pub(crate) span: Range, +} + +pub(crate) struct Scanner<'a> { + input: &'a [u8], + pos: usize, +} + +impl<'a> Scanner<'a> { + const fn new(input: &'a [u8]) -> Self { + Self { input, pos: 0 } + } + + fn err_at(&self, offset: usize, expected: &'static str) -> SerderError { + SerderError::NonCanonical { + offset, + expected, + found: self.input.get(offset).copied(), + } + } + + fn err(&self, expected: &'static str) -> SerderError { + self.err_at(self.pos, expected) + } + + fn peek(&self) -> Option { + self.input.get(self.pos).copied() + } + + /// Consume `lit` if it is next; report whether it was. + fn take_lit(&mut self, lit: &'static str) -> bool { + let Some(end) = self.pos.checked_add(lit.len()) else { + return false; + }; + if self.input.get(self.pos..end) == Some(lit.as_bytes()) { + self.pos = end; + true + } else { + false + } + } + + fn expect(&mut self, lit: &'static str) -> Result<(), SerderError> { + if self.take_lit(lit) { + Ok(()) + } else { + Err(self.err(lit)) + } + } + + fn advance(&mut self, by: usize, expected: &'static str) -> Result<(), SerderError> { + self.pos = self + .pos + .checked_add(by) + .ok_or_else(|| self.err(expected))?; + Ok(()) + } + + /// A canonical JSON string: no escapes, no control characters, UTF-8. + fn string(&mut self) -> Result, SerderError> { + self.expect("\"")?; + let start = self.pos; + loop { + match self.peek() { + Some(b'"') => break, + Some(b'\\') => { + return Err(self.err( + "unescaped string byte (canonical values never require escaping)", + )); + } + Some(b) if b < 0x20 => { + return Err(self.err("unescaped string byte (no control characters)")); + } + Some(_) => self.advance(1, "string byte")?, + None => return Err(self.err("closing '\"'")), + } + } + let span = start..self.pos; + let bytes = self + .input + .get(span.clone()) + .ok_or(SerderError::InvalidEventLayout("string span out of bounds"))?; + let value = + str::from_utf8(bytes).map_err(|_| self.err_at(start, "UTF-8 string value"))?; + self.expect("\"")?; + Ok(Spanned { value, span }) + } + + /// A canonical JSON integer: `0` or `[1-9][0-9]*`. No sign, no leading + /// zeros, no fraction or exponent. + fn integer(&mut self) -> Result<&'a str, SerderError> { + let start = self.pos; + match self.peek() { + Some(b'0') => { + self.advance(1, "digit")?; + if matches!(self.peek(), Some(b'0'..=b'9')) { + return Err(self.err("no leading zeros in canonical integer")); + } + } + Some(b'1'..=b'9') => { + self.advance(1, "digit")?; + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.advance(1, "digit")?; + } + } + _ => return Err(self.err("digit")), + } + let bytes = self + .input + .get(start..self.pos) + .ok_or(SerderError::InvalidEventLayout("integer span out of bounds"))?; + str::from_utf8(bytes).map_err(|_| self.err_at(start, "ASCII integer")) + } + + /// The input must be fully consumed. + fn finish(&self) -> Result<(), SerderError> { + if self.pos == self.input.len() { + Ok(()) + } else { + Err(self.err("end of input")) + } + } +} +``` + +And the first test block at the bottom of `canonical.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn non_canonical_at(e: &SerderError) -> Option<(usize, &'static str)> { + if let SerderError::NonCanonical { + offset, expected, .. + } = e + { + Some((*offset, expected)) + } else { + None + } + } + + #[test] + fn scanner_string_reads_value_and_span() { + let mut sc = Scanner::new(b"\"abc\"rest"); + let s = sc.string().unwrap(); + assert_eq!(s.value, "abc"); + assert_eq!(s.span, 1..4); + assert_eq!(sc.pos, 5); + } + + #[test] + fn scanner_string_rejects_escape() { + let mut sc = Scanner::new(b"\"a\\u0030\""); + let err = sc.string().unwrap_err(); + let (offset, _) = non_canonical_at(&err).expect("NonCanonical"); + assert_eq!(offset, 2, "the backslash byte is the violation"); + } + + #[test] + fn scanner_string_rejects_control_char() { + let mut sc = Scanner::new(b"\"a\x01b\""); + assert!(matches!( + sc.string(), + Err(SerderError::NonCanonical { offset: 2, .. }) + )); + } + + #[test] + fn scanner_string_rejects_unterminated() { + let mut sc = Scanner::new(b"\"abc"); + assert!(matches!( + sc.string(), + Err(SerderError::NonCanonical { + offset: 4, + found: None, + .. + }) + )); + } + + #[test] + fn scanner_string_rejects_non_utf8() { + let mut sc = Scanner::new(b"\"\xFF\xFE\""); + assert!(matches!( + sc.string(), + Err(SerderError::NonCanonical { offset: 1, .. }) + )); + } + + #[test] + fn scanner_string_accepts_multibyte_utf8() { + let mut sc = Scanner::new("\"héllo\"".as_bytes()); + assert_eq!(sc.string().unwrap().value, "héllo"); + } + + #[test] + fn scanner_integer_grammar() { + assert_eq!(Scanner::new(b"0,").integer().unwrap(), "0"); + assert_eq!(Scanner::new(b"10}").integer().unwrap(), "10"); + assert!(Scanner::new(b"01").integer().is_err(), "leading zero"); + assert!(Scanner::new(b"-1").integer().is_err(), "sign"); + assert!(Scanner::new(b"x").integer().is_err(), "non-digit"); + } + + #[test] + fn scanner_expect_reports_offset_and_found() { + let mut sc = Scanner::new(b"abc"); + let err = sc.expect("abd").unwrap_err(); + assert!(matches!( + err, + SerderError::NonCanonical { + offset: 0, + found: Some(b'a'), + .. + } + )); + } + + #[test] + fn scanner_finish_rejects_trailing() { + let mut sc = Scanner::new(b"ab"); + sc.expect("ab").unwrap(); + sc.finish().unwrap(); + let mut sc2 = Scanner::new(b"abX"); + sc2.expect("ab").unwrap(); + assert!(matches!( + sc2.finish(), + Err(SerderError::NonCanonical { + offset: 2, + found: Some(b'X'), + .. + }) + )); + } +} +``` + +- [ ] **Step 2.3: Run the tests, verify green** (they were written against the impl in the same step; verify they compile and pass — the failure mode to catch is a grammar off-by-one) + +Run: `nix develop --command cargo nextest run -p cesr-rs canonical::` +Expected: all `scanner_*` tests PASS. + +- [ ] **Step 2.4: Commit** + +```bash +git add cesr/src/serder/deserialize.rs cesr/src/serder/deserialize/canonical.rs +git commit -m "feat(serder): strict canonical scanner core (#142)" +``` + +--- + +### Task 3: Value grammars — arrays, thresholds, counts, seals + +**Files:** +- Modify: `cesr/src/serder/deserialize/canonical.rs` + +- [ ] **Step 3.1: Add parsed-value types** (below `Spanned`) + +```rust +/// A `kt`/`nt` threshold value as it appears on the wire. +pub(crate) enum ParsedTholder<'a> { + /// Hex string form, e.g. `"1"`, `"a"`. + Hex(&'a str), + /// keripy `intive=True` integer form, e.g. `1`. + Number(&'a str), + /// Weighted clauses; a flat array is normalized to a single clause. + Weighted(Vec>), +} + +/// A `bt` witness-threshold value as it appears on the wire. +pub(crate) enum ParsedCount<'a> { + /// Hex string form. + Hex(&'a str), + /// keripy `intive=True` integer form. + Number(&'a str), +} + +/// A seal object, one of the five fixed shapes. +pub(crate) enum ParsedSeal<'a> { + Digest { d: &'a str }, + Root { rd: &'a str }, + Source { s: &'a str, d: &'a str }, + Event { i: &'a str, s: &'a str, d: &'a str }, + Last { i: &'a str }, +} +``` + +- [ ] **Step 3.2: Add composite grammar functions** + +```rust +fn string_array<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> { + sc.expect("[")?; + let mut items = Vec::new(); + if sc.take_lit("]") { + return Ok(items); + } + loop { + items.push(sc.string()?.value); + if sc.take_lit("]") { + return Ok(items); + } + sc.expect(",")?; + } +} + +fn tholder<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> { + match sc.peek() { + Some(b'"') => Ok(ParsedTholder::Hex(sc.string()?.value)), + Some(b'0'..=b'9') => Ok(ParsedTholder::Number(sc.integer()?)), + Some(b'[') => weighted(sc), + _ => Err(sc.err("threshold (hex string, integer, or weighted array)")), + } +} + +fn weighted<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> { + sc.expect("[")?; + if sc.take_lit("]") { + return Ok(ParsedTholder::Weighted(Vec::new())); + } + match sc.peek() { + Some(b'"') => { + let mut clause = Vec::new(); + loop { + clause.push(sc.string()?.value); + if sc.take_lit("]") { + return Ok(ParsedTholder::Weighted(vec![clause])); + } + sc.expect(",")?; + } + } + Some(b'[') => { + let mut clauses = Vec::new(); + loop { + clauses.push(string_array(sc)?); + if sc.take_lit("]") { + return Ok(ParsedTholder::Weighted(clauses)); + } + sc.expect(",")?; + } + } + _ => Err(sc.err("weight fraction string or clause array")), + } +} + +fn count<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> { + match sc.peek() { + Some(b'"') => Ok(ParsedCount::Hex(sc.string()?.value)), + Some(b'0'..=b'9') => Ok(ParsedCount::Number(sc.integer()?)), + _ => Err(sc.err("count (hex string or integer)")), + } +} + +/// One seal object. Field order per variant is fixed (matches the writer +/// and keripy's namedtuple serialization order). +fn seal<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> { + sc.expect("{")?; + if sc.take_lit("\"d\":") { + let d = sc.string()?.value; + sc.expect("}")?; + return Ok(ParsedSeal::Digest { d }); + } + if sc.take_lit("\"rd\":") { + let rd = sc.string()?.value; + sc.expect("}")?; + return Ok(ParsedSeal::Root { rd }); + } + if sc.take_lit("\"s\":") { + let s = sc.string()?.value; + sc.expect(",\"d\":")?; + let d = sc.string()?.value; + sc.expect("}")?; + return Ok(ParsedSeal::Source { s, d }); + } + if sc.take_lit("\"i\":") { + let i = sc.string()?.value; + if sc.take_lit("}") { + return Ok(ParsedSeal::Last { i }); + } + sc.expect(",\"s\":")?; + let s = sc.string()?.value; + sc.expect(",\"d\":")?; + let d = sc.string()?.value; + sc.expect("}")?; + return Ok(ParsedSeal::Event { i, s, d }); + } + Err(sc.err("seal key (\"d\", \"rd\", \"s\", or \"i\")")) +} + +fn seal_array<'a>(sc: &mut Scanner<'a>) -> Result>, SerderError> { + sc.expect("[")?; + let mut items = Vec::new(); + if sc.take_lit("]") { + return Ok(items); + } + loop { + items.push(seal(sc)?); + if sc.take_lit("]") { + return Ok(items); + } + sc.expect(",")?; + } +} +``` + +- [ ] **Step 3.3: Add grammar tests** (inside the existing `mod tests`) + +```rust + #[test] + fn string_array_shapes() { + assert!(string_array(&mut Scanner::new(b"[]")).unwrap().is_empty()); + assert_eq!( + string_array(&mut Scanner::new(b"[\"a\",\"b\"]")).unwrap(), + vec!["a", "b"] + ); + assert!(string_array(&mut Scanner::new(b"[\"a\",]")).is_err(), "trailing comma"); + assert!(string_array(&mut Scanner::new(b"[ \"a\"]")).is_err(), "whitespace"); + } + + #[test] + fn tholder_shapes() { + assert!(matches!( + tholder(&mut Scanner::new(b"\"a\"")).unwrap(), + ParsedTholder::Hex("a") + )); + assert!(matches!( + tholder(&mut Scanner::new(b"2,")).unwrap(), + ParsedTholder::Number("2") + )); + let ParsedTholder::Weighted(flat) = + tholder(&mut Scanner::new(b"[\"1/2\",\"1/2\"]")).unwrap() + else { + unreachable!() + }; + assert_eq!(flat, vec![vec!["1/2", "1/2"]]); + let ParsedTholder::Weighted(nested) = + tholder(&mut Scanner::new(b"[[\"1/2\",\"1/2\"],[\"1\"]]")).unwrap() + else { + unreachable!() + }; + assert_eq!(nested, vec![vec!["1/2", "1/2"], vec!["1"]]); + let ParsedTholder::Weighted(empty) = tholder(&mut Scanner::new(b"[]")).unwrap() else { + unreachable!() + }; + assert!(empty.is_empty()); + assert!(tholder(&mut Scanner::new(b"true")).is_err()); + } + + #[test] + fn seal_shapes() { + assert!(matches!( + seal(&mut Scanner::new(b"{\"d\":\"X\"}")).unwrap(), + ParsedSeal::Digest { d: "X" } + )); + assert!(matches!( + seal(&mut Scanner::new(b"{\"rd\":\"X\"}")).unwrap(), + ParsedSeal::Root { rd: "X" } + )); + assert!(matches!( + seal(&mut Scanner::new(b"{\"s\":\"1\",\"d\":\"X\"}")).unwrap(), + ParsedSeal::Source { s: "1", d: "X" } + )); + assert!(matches!( + seal(&mut Scanner::new(b"{\"i\":\"I\",\"s\":\"1\",\"d\":\"X\"}")).unwrap(), + ParsedSeal::Event { i: "I", s: "1", d: "X" } + )); + assert!(matches!( + seal(&mut Scanner::new(b"{\"i\":\"I\"}")).unwrap(), + ParsedSeal::Last { i: "I" } + )); + assert!( + seal(&mut Scanner::new(b"{\"d\":\"X\",\"s\":\"1\"}")).is_err(), + "out-of-order seal fields are non-canonical" + ); + assert!( + seal(&mut Scanner::new(b"{\"x\":\"X\"}")).is_err(), + "unknown seal key" + ); + } +``` + +- [ ] **Step 3.4: Run tests** + +Run: `nix develop --command cargo nextest run -p cesr-rs canonical::` +Expected: PASS. + +- [ ] **Step 3.5: Commit** + +```bash +git add cesr/src/serder/deserialize/canonical.rs +git commit -m "feat(serder): canonical value grammars — arrays, tholder, count, seal (#142)" +``` + +--- + +### Task 4: Head validation, per-ilk grammars, entry points + +**Files:** +- Modify: `cesr/src/serder/deserialize/canonical.rs` + +- [ ] **Step 4.1: Add parsed-event types** + +```rust +/// A parsed inception (`icp`) body: borrowed field views plus SAID spans. +pub(crate) struct ParsedIcp<'a> { + pub(crate) said: Spanned<'a>, + pub(crate) prefix: Spanned<'a>, + pub(crate) sn: &'a str, + pub(crate) threshold: ParsedTholder<'a>, + pub(crate) keys: Vec<&'a str>, + pub(crate) next_threshold: ParsedTholder<'a>, + pub(crate) next_keys: Vec<&'a str>, + pub(crate) witness_threshold: ParsedCount<'a>, + pub(crate) witnesses: Vec<&'a str>, + pub(crate) config: Vec<&'a str>, + pub(crate) anchors: Vec>, +} + +/// A parsed delegated inception (`dip`): an inception plus the delegator. +pub(crate) struct ParsedDip<'a> { + pub(crate) icp: ParsedIcp<'a>, + pub(crate) delegator: &'a str, +} + +/// A parsed rotation (`rot`) or delegated rotation (`drt`) body. +pub(crate) struct ParsedRot<'a> { + pub(crate) said: Spanned<'a>, + pub(crate) prefix: &'a str, + pub(crate) sn: &'a str, + pub(crate) prior: &'a str, + pub(crate) threshold: ParsedTholder<'a>, + pub(crate) keys: Vec<&'a str>, + pub(crate) next_threshold: ParsedTholder<'a>, + pub(crate) next_keys: Vec<&'a str>, + pub(crate) witness_threshold: ParsedCount<'a>, + pub(crate) witness_removals: Vec<&'a str>, + pub(crate) witness_additions: Vec<&'a str>, + pub(crate) anchors: Vec>, +} + +/// A parsed interaction (`ixn`) body. +pub(crate) struct ParsedIxn<'a> { + pub(crate) said: Spanned<'a>, + pub(crate) prefix: &'a str, + pub(crate) sn: &'a str, + pub(crate) prior: &'a str, + pub(crate) anchors: Vec>, +} + +/// Any parsed event, dispatched on the wire ilk. +pub(crate) enum ParsedEvent<'a> { + Inception(ParsedIcp<'a>), + Rotation(ParsedRot<'a>), + Interaction(ParsedIxn<'a>), + DelegatedInception(ParsedDip<'a>), + DelegatedRotation(ParsedRot<'a>), +} +``` + +- [ ] **Step 4.2: Head parsing.** Validates the version string at its fixed offset — kind, widths, and `size == raw.len()` — with **zero** JSON parsing (replaces `validate_version_string`'s full `serde_json::from_slice`): + +```rust +/// Parse and validate the fixed head `{"v":"<17-byte version string>","t":` +/// and return the scanner positioned at the ilk value plus the ilk itself. +fn head<'a>(raw: &'a [u8]) -> Result<(Scanner<'a>, Spanned<'a>), SerderError> { + let mut sc = Scanner::new(raw); + sc.expect("{\"v\":\"")?; + let vs_start = sc.pos; + let vs_end = vs_start + .checked_add(VERSION_STRING_LEN) + .ok_or(SerderError::InvalidEventLayout("version span overflow"))?; + let vs_bytes = raw + .get(vs_start..vs_end) + .ok_or_else(|| sc.err("17-byte version string"))?; + let vs_str = str::from_utf8(vs_bytes).map_err(|_| sc.err("ASCII version string"))?; + let vs = VersionString::parse(vs_str)?; + if vs.kind != SerKind::Json { + return Err(SerderError::InvalidVersionString(format!( + "expected JSON, got {}", + vs.kind.as_str() + ))); + } + let expected_size = usize::try_from(vs.size) + .map_err(|e| SerderError::InvalidVersionString(e.to_string()))?; + if expected_size != raw.len() { + return Err(SerderError::InvalidVersionString(format!( + "version string size {} does not match actual size {}", + expected_size, + raw.len() + ))); + } + sc.pos = vs_end; + sc.expect("\",\"t\":")?; + let ilk = sc.string()?; + Ok((sc, ilk)) +} +``` + +(Add `use alloc::string::ToString;` to the alloc import list for `e.to_string()`.) + +- [ ] **Step 4.3: Per-ilk bodies and public (crate) entry points** + +```rust +fn icp_fields<'a>(sc: &mut Scanner<'a>) -> Result, SerderError> { + sc.expect(",\"d\":")?; + let said = sc.string()?; + sc.expect(",\"i\":")?; + let prefix = sc.string()?; + sc.expect(",\"s\":")?; + let sn = sc.string()?.value; + sc.expect(",\"kt\":")?; + let threshold = tholder(sc)?; + sc.expect(",\"k\":")?; + let keys = string_array(sc)?; + sc.expect(",\"nt\":")?; + let next_threshold = tholder(sc)?; + sc.expect(",\"n\":")?; + let next_keys = string_array(sc)?; + sc.expect(",\"bt\":")?; + let witness_threshold = count(sc)?; + sc.expect(",\"b\":")?; + let witnesses = string_array(sc)?; + sc.expect(",\"c\":")?; + let config = string_array(sc)?; + sc.expect(",\"a\":")?; + let anchors = seal_array(sc)?; + Ok(ParsedIcp { + said, + prefix, + sn, + threshold, + keys, + next_threshold, + next_keys, + witness_threshold, + witnesses, + config, + anchors, + }) +} + +fn rot_body<'a>(mut sc: Scanner<'a>) -> Result, SerderError> { + sc.expect(",\"d\":")?; + let said = sc.string()?; + sc.expect(",\"i\":")?; + let prefix = sc.string()?.value; + sc.expect(",\"s\":")?; + let sn = sc.string()?.value; + sc.expect(",\"p\":")?; + let prior = sc.string()?.value; + sc.expect(",\"kt\":")?; + let threshold = tholder(&mut sc)?; + sc.expect(",\"k\":")?; + let keys = string_array(&mut sc)?; + sc.expect(",\"nt\":")?; + let next_threshold = tholder(&mut sc)?; + sc.expect(",\"n\":")?; + let next_keys = string_array(&mut sc)?; + sc.expect(",\"bt\":")?; + let witness_threshold = count(&mut sc)?; + sc.expect(",\"br\":")?; + let witness_removals = string_array(&mut sc)?; + sc.expect(",\"ba\":")?; + let witness_additions = string_array(&mut sc)?; + sc.expect(",\"a\":")?; + let anchors = seal_array(&mut sc)?; + sc.expect("}")?; + sc.finish()?; + Ok(ParsedRot { + said, + prefix, + sn, + prior, + threshold, + keys, + next_threshold, + next_keys, + witness_threshold, + witness_removals, + witness_additions, + anchors, + }) +} + +fn ixn_body<'a>(mut sc: Scanner<'a>) -> Result, SerderError> { + sc.expect(",\"d\":")?; + let said = sc.string()?; + sc.expect(",\"i\":")?; + let prefix = sc.string()?.value; + sc.expect(",\"s\":")?; + let sn = sc.string()?.value; + sc.expect(",\"p\":")?; + let prior = sc.string()?.value; + sc.expect(",\"a\":")?; + let anchors = seal_array(&mut sc)?; + sc.expect("}")?; + sc.finish()?; + Ok(ParsedIxn { + said, + prefix, + sn, + prior, + anchors, + }) +} + +fn icp_body<'a>(mut sc: Scanner<'a>) -> Result, SerderError> { + let fields = icp_fields(&mut sc)?; + sc.expect("}")?; + sc.finish()?; + Ok(fields) +} + +fn dip_body<'a>(mut sc: Scanner<'a>) -> Result, SerderError> { + let icp = icp_fields(&mut sc)?; + sc.expect(",\"di\":")?; + let delegator = sc.string()?.value; + sc.expect("}")?; + sc.finish()?; + Ok(ParsedDip { icp, delegator }) +} + +fn require_ilk(sc: &Scanner<'_>, ilk: &Spanned<'_>, expected: &'static str) -> Result<(), SerderError> { + if ilk.value == expected { + Ok(()) + } else { + Err(sc.err_at(ilk.span.start, expected)) + } +} + +pub(crate) fn parse_event(raw: &[u8]) -> Result, SerderError> { + let (sc, ilk) = head(raw)?; + match ilk.value { + "icp" => Ok(ParsedEvent::Inception(icp_body(sc)?)), + "rot" => Ok(ParsedEvent::Rotation(rot_body(sc)?)), + "ixn" => Ok(ParsedEvent::Interaction(ixn_body(sc)?)), + "dip" => Ok(ParsedEvent::DelegatedInception(dip_body(sc)?)), + "drt" => Ok(ParsedEvent::DelegatedRotation(rot_body(sc)?)), + other => Err(SerderError::UnknownIlk(other.to_owned())), + } +} + +pub(crate) fn parse_inception(raw: &[u8]) -> Result, SerderError> { + let (sc, ilk) = head(raw)?; + require_ilk(&sc, &ilk, "icp")?; + icp_body(sc) +} + +pub(crate) fn parse_rotation(raw: &[u8]) -> Result, SerderError> { + let (sc, ilk) = head(raw)?; + require_ilk(&sc, &ilk, "rot")?; + rot_body(sc) +} + +pub(crate) fn parse_interaction(raw: &[u8]) -> Result, SerderError> { + let (sc, ilk) = head(raw)?; + require_ilk(&sc, &ilk, "ixn")?; + ixn_body(sc) +} + +pub(crate) fn parse_delegated_inception(raw: &[u8]) -> Result, SerderError> { + let (sc, ilk) = head(raw)?; + require_ilk(&sc, &ilk, "dip")?; + dip_body(sc) +} + +pub(crate) fn parse_delegated_rotation(raw: &[u8]) -> Result, SerderError> { + let (sc, ilk) = head(raw)?; + require_ilk(&sc, &ilk, "drt")?; + rot_body(sc) +} +``` + +- [ ] **Step 4.4: Grammar tests against real writer output.** Add to `mod tests` (fixtures via the existing serialize path): + +```rust + use crate::core::matter::builder::MatterBuilder; + use crate::core::matter::code::{DigestCode, VerKeyCode}; + use crate::core::primitives::{Prefixer, Saider, Seqner, Tholder, Verfer}; + use crate::keri::{ConfigTrait, InceptionEvent, InteractionEvent, Seal}; + use crate::serder::serialize::{serialize_inception, serialize_interaction}; + use alloc::borrow::Cow; + + fn make_prefixer() -> Prefixer<'static> { + MatterBuilder::new() + .with_code(VerKeyCode::Ed25519) + .with_raw(Cow::<[u8]>::Owned(vec![0u8; 32])) + .unwrap() + .build() + .unwrap() + } + + fn make_saider() -> Saider<'static> { + MatterBuilder::new() + .with_code(DigestCode::Blake3_256) + .with_raw(Cow::<[u8]>::Owned(vec![1u8; 32])) + .unwrap() + .build() + .unwrap() + } + + fn make_verfer() -> Verfer<'static> { + MatterBuilder::new() + .with_code(VerKeyCode::Ed25519) + .with_raw(Cow::<[u8]>::Owned(vec![1u8; 32])) + .unwrap() + .build() + .unwrap() + } + + fn probe_icp_bytes() -> Vec { + let event = InceptionEvent::new( + make_prefixer().into(), + Seqner::new(0), + make_saider(), + vec![make_verfer()], + Tholder::Simple(1), + vec![make_saider()], + Tholder::Simple(1), + vec![make_prefixer()], + 1, + vec![ConfigTrait::EstOnly], + vec![Seal::Digest { d: make_saider() }], + ); + serialize_inception(&event).unwrap().as_bytes().to_vec() + } + + fn probe_ixn_bytes() -> Vec { + let event = InteractionEvent::new( + make_prefixer().into(), + Seqner::new(3), + make_saider(), + make_saider(), + vec![], + ); + serialize_interaction(&event).unwrap().as_bytes().to_vec() + } + + #[test] + fn parse_event_reads_writer_output_icp() { + let raw = probe_icp_bytes(); + let ParsedEvent::Inception(p) = parse_event(&raw).unwrap() else { + unreachable!() + }; + assert_eq!(p.sn, "0"); + assert_eq!(p.keys.len(), 1); + assert_eq!(p.config, vec!["EO"]); + assert_eq!(p.anchors.len(), 1); + assert_eq!(p.said.span.len(), 44); + assert_eq!( + &raw[p.said.span.clone()], + p.said.value.as_bytes(), + "span must address the value bytes in raw" + ); + assert_eq!(&raw[p.prefix.span.clone()], p.prefix.value.as_bytes()); + } + + #[test] + fn per_ilk_entry_rejects_wrong_ilk() { + let raw = probe_ixn_bytes(); + assert!(matches!( + parse_rotation(&raw), + Err(SerderError::NonCanonical { expected: "rot", .. }) + )); + } + + #[test] + fn unknown_ilk_is_typed() { + let mut raw = probe_ixn_bytes(); + let pos = raw.windows(5).position(|w| w == b"\"ixn\"").unwrap(); + raw[pos + 1..pos + 4].copy_from_slice(b"xxx"); + assert!(matches!( + parse_event(&raw), + Err(SerderError::UnknownIlk(ref s)) if s == "xxx" + )); + } + + #[test] + fn whitespace_with_consistent_size_is_non_canonical() { + // Insert one space after the first comma AND fix the version size so + // the length check passes — the grammar itself must reject it. + let raw = probe_ixn_bytes(); + let comma = raw.iter().position(|b| *b == b',').unwrap(); + let mut padded = Vec::with_capacity(raw.len() + 1); + padded.extend_from_slice(&raw[..=comma]); + padded.push(b' '); + padded.extend_from_slice(&raw[comma + 1..]); + fix_size(&mut padded); + assert!(matches!( + parse_event(&padded), + Err(SerderError::NonCanonical { .. }) + )); + } + + #[test] + fn duplicate_field_is_non_canonical() { + // Overwrite the `,"i":` key with a second `,"d":` — same length, so + // the version size stays consistent; the grammar must reject it. + let mut raw = probe_ixn_bytes(); + let pos = raw.windows(5).position(|w| w == b",\"i\":").unwrap(); + raw[pos..pos + 5].copy_from_slice(b",\"d\":"); + assert!(matches!( + parse_event(&raw), + Err(SerderError::NonCanonical { .. }) + )); + } + + #[test] + fn reordered_fields_are_non_canonical() { + // Swap the `"s"` and `"p"` key names (same length) in an ixn. + let mut raw = probe_ixn_bytes(); + let s_pos = raw.windows(5).position(|w| w == b",\"s\":").unwrap(); + let p_pos = raw.windows(5).position(|w| w == b",\"p\":").unwrap(); + raw[s_pos + 2] = b'p'; + raw[p_pos + 2] = b's'; + assert!(matches!( + parse_event(&raw), + Err(SerderError::NonCanonical { .. }) + )); + } + + #[test] + fn escape_in_value_is_non_canonical() { + // Replace sn value "3" with "3" and fix the size field. + let raw = probe_ixn_bytes(); + let pos = raw.windows(8).position(|w| w == b",\"s\":\"3\"").unwrap(); + let mut mutated = Vec::with_capacity(raw.len() + 5); + mutated.extend_from_slice(&raw[..pos]); + mutated.extend_from_slice(b",\"s\":\"\\u0033\""); + mutated.extend_from_slice(&raw[pos + 8..]); + fix_size(&mut mutated); + assert!(matches!( + parse_event(&mutated), + Err(SerderError::NonCanonical { .. }) + )); + } + + #[test] + fn trailing_bytes_are_non_canonical() { + let mut raw = probe_ixn_bytes(); + raw.push(b'X'); + fix_size(&mut raw); + assert!(matches!( + parse_event(&raw), + Err(SerderError::NonCanonical { .. }) + )); + } + + #[test] + fn length_lie_is_still_invalid_version_string() { + // Without fixing the size field, a padded input fails the size check + // first — preserving the #139 defence. + let mut raw = probe_ixn_bytes(); + raw.push(b'X'); + assert!(matches!( + parse_event(&raw), + Err(SerderError::InvalidVersionString(_)) + )); + } + + #[test] + fn every_strict_prefix_is_rejected_without_panicking() { + let raw = probe_icp_bytes(); + for cut in 0..raw.len() { + assert!( + parse_event(&raw[..cut]).is_err(), + "truncation at {cut} must be rejected" + ); + } + } + + /// Rewrite the six size hex digits (bytes 16..22) to the buffer's actual + /// length so grammar probes are not masked by the #139 length check. + fn fix_size(raw: &mut [u8]) { + let size = raw.len(); + let hex = format!("{size:06x}"); + raw[16..22].copy_from_slice(hex.as_bytes()); + } +``` + +- [ ] **Step 4.5: Run tests** + +Run: `nix develop --command cargo nextest run -p cesr-rs canonical::` +Expected: PASS. + +- [ ] **Step 4.6: Commit** + +```bash +git add cesr/src/serder/deserialize/canonical.rs +git commit -m "feat(serder): per-ilk strict grammars with fixed-offset version validation (#142)" +``` + +--- + +### Task 5: Offset-based SAID verification + +**Files:** +- Modify: `cesr/src/serder/said.rs` + +- [ ] **Step 5.1: Add `DUMMY_BYTE` and `verify_said_spans`** (below `DUMMY_CHAR`) + +```rust +/// Byte form of [`DUMMY_CHAR`] for in-place span filling. +pub(crate) const DUMMY_BYTE: u8 = b'#'; +``` + +```rust +use core::ops::Range; +``` + +(add to the top-of-file imports), then: + +```rust +/// Verify a SAID by span: copy `raw` once into a scratch buffer, overwrite +/// the SAID value span (and the prefix span for double-SAID events) with +/// [`DUMMY_BYTE`], hash, and compare against `said_value`. +/// +/// Spans come from the canonical parser and must address the qb64 value +/// bytes exactly (quotes excluded). This replaces the historical +/// parse-mutate-re-render verification with one allocation and one hash. +/// +/// # Errors +/// +/// Returns [`SerderError::SaidMismatch`] if the computed digest differs, +/// [`SerderError::InvalidEventLayout`] if a span is out of bounds, or +/// [`SerderError::DigestError`] on hash failure. +pub(crate) fn verify_said_spans( + raw: &[u8], + said_value: &str, + said_span: &Range, + prefix_span: Option<&Range>, + code: DigestCode, +) -> Result<(), SerderError> { + let mut scratch = raw.to_vec(); + fill_span(&mut scratch, said_span)?; + if let Some(span) = prefix_span { + fill_span(&mut scratch, span)?; + } + let computed = compute_digest(&scratch, code)?; + let computed_qb64 = to_qb64_string(&computed); + if said_value == computed_qb64 { + Ok(()) + } else { + Err(SerderError::SaidMismatch { + expected: said_value.to_owned(), + computed: computed_qb64, + }) + } +} + +fn fill_span(scratch: &mut [u8], span: &Range) -> Result<(), SerderError> { + scratch + .get_mut(span.clone()) + .ok_or(SerderError::InvalidEventLayout("SAID span out of bounds"))? + .fill(DUMMY_BYTE); + Ok(()) +} +``` + +(`alloc::vec::Vec` is already importable; extend the `alloc` import list with `vec::Vec` if missing.) + +- [ ] **Step 5.2: Tests** (in `said.rs` `mod tests`) — verify against the writer's own orchestration, which computes SAIDs the same way: + +```rust + use crate::core::matter::builder::MatterBuilder; + use crate::core::matter::code::VerKeyCode; + use crate::core::primitives::{Seqner, Tholder}; + use crate::keri::InteractionEvent; + use crate::serder::serialize::serialize_interaction; + use alloc::borrow::Cow; + + fn probe_ixn_raw() -> (alloc::vec::Vec, String) { + let prefixer = MatterBuilder::new() + .with_code(VerKeyCode::Ed25519) + .with_raw(Cow::<[u8]>::Owned(vec![0u8; 32])) + .unwrap() + .build() + .unwrap(); + let saider_fixture = compute_digest(b"seed", DigestCode::Blake3_256).unwrap(); + let event = InteractionEvent::new( + prefixer.into(), + Seqner::new(1), + saider_fixture.clone(), + saider_fixture, + vec![], + ); + let ser = serialize_interaction(&event).unwrap(); + let said = to_qb64_string(ser.said()); + (ser.as_bytes().to_vec(), said) + } + + #[test] + fn verify_said_spans_accepts_writer_output() { + let (raw, said) = probe_ixn_raw(); + let start = raw + .windows(6) + .position(|w| w == b"\"d\":\"E") + .expect("d field present") + + 5; + let span = start..start + 44; + assert_eq!(&raw[span.clone()], said.as_bytes()); + verify_said_spans(&raw, &said, &span, None, DigestCode::Blake3_256) + .expect("writer output must verify"); + } + + #[test] + fn verify_said_spans_rejects_tamper() { + let (mut raw, said) = probe_ixn_raw(); + let start = raw + .windows(6) + .position(|w| w == b"\"d\":\"E") + .unwrap() + + 5; + let span = start..start + 44; + let s_pos = raw.windows(8).position(|w| w == b",\"s\":\"1\"").unwrap(); + raw[s_pos + 6] = b'2'; + assert!(matches!( + verify_said_spans(&raw, &said, &span, None, DigestCode::Blake3_256), + Err(SerderError::SaidMismatch { .. }) + )); + } + + #[test] + fn verify_said_spans_rejects_out_of_bounds_span() { + let (raw, said) = probe_ixn_raw(); + let bogus = raw.len()..raw.len() + 44; + assert!(matches!( + verify_said_spans(&raw, &said, &bogus, None, DigestCode::Blake3_256), + Err(SerderError::InvalidEventLayout(_)) + )); + } +``` + +(`SerderError` and `String` need importing in the test module if not present: `use crate::serder::error::SerderError;` and `use alloc::string::String;`.) + +- [ ] **Step 5.3: Run tests** + +Run: `nix develop --command cargo nextest run -p cesr-rs said::` +Expected: PASS. + +- [ ] **Step 5.4: Commit** + +```bash +git add cesr/src/serder/said.rs +git commit -m "feat(serder): offset-based SAID verification — copy-once, fill spans, hash (#142)" +``` + +--- + +### Task 6: Rewire the public deserializers onto the strict parser + +**Files:** +- Create: `cesr/src/serder/deserialize/reference.rs` (old path, `#[cfg(test)]` oracle) +- Modify: `cesr/src/serder/deserialize.rs` + +This is the pivotal task. The old `serde_json::Value` implementation moves verbatim into `reference.rs`; the public entry points re-implement over `canonical` + `verify_said_spans`. The qb64/sn/weight converters stay in `deserialize.rs` and are shared by both paths, so conversion behavior is identical by construction. + +- [ ] **Step 6.1: Create `reference.rs`** — move these items out of `deserialize.rs` **unchanged** (cut-paste, adjusting only paths to `super::`): `validate_version_string`, `verify_said_single`, `verify_said_double`, `tholder_from_json`, `parse_witness_threshold`, `seal_from_json`, `parse_seal_array`, `parse_config_array`, `parse_qb64_prefixer_array`, `parse_qb64_verfer_array`, `parse_qb64_diger_array`, `get_str`, `get_field`, and the five `deserialize_*` bodies plus `deserialize_event` (renamed inside the module to the same names). Header: + +```rust +//! The pre-#142 tolerant read path (`serde_json::Value` + re-render SAID +//! verification), preserved verbatim as the differential-test oracle for +//! the strict canonical parser. Test-only: never compiled into production. + +use super::{infer_digest_code, map_qb64_error, parse_qb64_diger, parse_qb64_identifier, + parse_qb64_prefixer, parse_qb64_saider, parse_qb64_verfer, parse_sn, parse_weight}; +use crate::core::matter::code::DigestCode; +use crate::core::matter::error::ValidationError; +use crate::core::primitives::{Diger, Prefixer, Seqner, Tholder, Verfer}; +use crate::keri::{ + ConfigTrait, DelegatedInceptionEvent, DelegatedRotationEvent, Identifier, Ilk, + InceptionEvent, InteractionEvent, KeriEvent, RotationEvent, Seal, +}; +use crate::serder::error::SerderError; +use crate::serder::primitives::to_qb64_string; +use crate::serder::said::{compute_digest, said_placeholder}; +use crate::serder::version::{SerKind, VERSION_STRING_LEN, VersionString}; +use alloc::{borrow::ToOwned, format, string::ToString, vec, vec::Vec}; +use serde_json::Value; +``` + +In `deserialize.rs`, declare it: + +```rust +#[cfg(test)] +pub(crate) mod reference; +``` + +Move the old unit tests that directly exercise Value-based helpers (`tholder_simple_from_json`, `tholder_weighted_from_json`, `tholder_invalid_returns_error`, `seal_*_from_json`, `tholder_from_json_integer`, `parse_witness_threshold_integer`) into a `mod tests` inside `reference.rs` — they pin the oracle's behavior. + +- [ ] **Step 6.2: Write the strict conversion layer in `deserialize.rs`** (replacing the moved code; the shared helpers `parse_qb64_*`, `parse_sn`, `parse_weight`, `map_qb64_error`, `infer_digest_code` remain where they are): + +```rust +use crate::serder::deserialize::canonical::{ + ParsedCount, ParsedDip, ParsedEvent, ParsedIcp, ParsedIxn, ParsedRot, ParsedSeal, + ParsedTholder, Spanned, +}; +use crate::serder::said::verify_said_spans; +``` + +```rust +fn tholder_from_parsed(t: &ParsedTholder<'_>) -> Result { + match t { + ParsedTholder::Hex(s) => { + let n = u64::from_str_radix(s, 16).map_err(|_| SerderError::InvalidPrimitive { + field: "kt", + source: ValidationError::UnknownMatterCode(format!("invalid hex threshold: {s}")), + })?; + Ok(Tholder::Simple(n)) + } + ParsedTholder::Number(s) => { + let n = s.parse::().map_err(|_| SerderError::InvalidPrimitive { + field: "kt", + source: ValidationError::UnknownMatterCode(format!("invalid integer threshold: {s}")), + })?; + Ok(Tholder::Simple(n)) + } + ParsedTholder::Weighted(clauses) => { + let parsed: Result>, SerderError> = clauses + .iter() + .map(|clause| clause.iter().map(|w| parse_weight(w)).collect()) + .collect(); + Ok(Tholder::Weighted(parsed?)) + } + } +} + +fn witness_threshold_from_parsed(c: &ParsedCount<'_>) -> Result { + let n = match c { + ParsedCount::Hex(s) => { + u128::from_str_radix(s, 16).map_err(|_| SerderError::InvalidPrimitive { + field: "bt", + source: ValidationError::UnknownMatterCode(format!("invalid hex bt: {s}")), + })? + } + ParsedCount::Number(s) => { + s.parse::().map_err(|_| SerderError::InvalidPrimitive { + field: "bt", + source: ValidationError::UnknownMatterCode(format!("invalid integer bt: {s}")), + })? + } + }; + u32::try_from(n).map_err(|_| SerderError::InvalidPrimitive { + field: "bt", + source: ValidationError::UnknownMatterCode(format!( + "witness threshold {n} exceeds u32::MAX" + )), + }) +} + +fn seal_from_parsed(seal: &ParsedSeal<'_>) -> Result { + match seal { + ParsedSeal::Digest { d } => Ok(Seal::Digest { + d: parse_qb64_saider(d, "d")?, + }), + ParsedSeal::Root { rd } => Ok(Seal::Root { + rd: parse_qb64_saider(rd, "rd")?, + }), + ParsedSeal::Source { s, d } => Ok(Seal::Source { + s: Seqner::new(parse_sn(s)?), + d: parse_qb64_saider(d, "d")?, + }), + ParsedSeal::Event { i, s, d } => Ok(Seal::Event { + i: parse_qb64_prefixer(i, "i")?, + s: Seqner::new(parse_sn(s)?), + d: parse_qb64_saider(d, "d")?, + }), + ParsedSeal::Last { i } => Ok(Seal::Last { + i: parse_qb64_prefixer(i, "i")?, + }), + } +} + +fn config_from_parsed(config: &[&str]) -> Result, SerderError> { + config + .iter() + .map(|s| ConfigTrait::from_code(s).map_err(|_| SerderError::UnknownIlk((*s).to_owned()))) + .collect() +} + +fn verfers_from_parsed( + items: &[&str], + field: &'static str, +) -> Result>, SerderError> { + items.iter().map(|s| parse_qb64_verfer(s, field)).collect() +} + +fn prefixers_from_parsed( + items: &[&str], + field: &'static str, +) -> Result>, SerderError> { + items.iter().map(|s| parse_qb64_prefixer(s, field)).collect() +} + +fn digers_from_parsed( + items: &[&str], + field: &'static str, +) -> Result>, SerderError> { + items.iter().map(|s| parse_qb64_diger(s, field)).collect() +} + +fn anchors_from_parsed(anchors: &[ParsedSeal<'_>]) -> Result, SerderError> { + anchors.iter().map(seal_from_parsed).collect() +} +``` + +- [ ] **Step 6.3: SAID verification helpers over parsed spans** (in `deserialize.rs`; preserves today's semantics — double-SAID only when `d == i`): + +```rust +fn verify_single_said(raw: &[u8], said: &Spanned<'_>) -> Result<(), SerderError> { + let code = infer_digest_code(said.value)?; + verify_said_spans(raw, said.value, &said.span, None, code) +} + +fn verify_inception_said(raw: &[u8], parsed: &ParsedIcp<'_>) -> Result<(), SerderError> { + let code = infer_digest_code(parsed.said.value)?; + let prefix_span = + (parsed.said.value == parsed.prefix.value).then_some(&parsed.prefix.span); + verify_said_spans(raw, parsed.said.value, &parsed.said.span, prefix_span, code) +} +``` + +- [ ] **Step 6.4: Re-implement the six public entry points** (doc comments stay; update the SAID wording to "verified in place over the raw bytes" and add `SerderError::NonCanonical` to the `# Errors` lists): + +```rust +pub fn deserialize_event(raw: &[u8]) -> Result { + match canonical::parse_event(raw)? { + ParsedEvent::Inception(p) => { + verify_inception_said(raw, &p)?; + Ok(KeriEvent::Inception(build_inception(&p)?)) + } + ParsedEvent::Rotation(p) => { + verify_single_said(raw, &p.said)?; + Ok(KeriEvent::Rotation(build_rotation(&p)?)) + } + ParsedEvent::Interaction(p) => { + verify_single_said(raw, &p.said)?; + Ok(KeriEvent::Interaction(build_interaction(&p)?)) + } + ParsedEvent::DelegatedInception(p) => { + verify_inception_said(raw, &p.icp)?; + Ok(KeriEvent::DelegatedInception(build_delegated_inception(&p)?)) + } + ParsedEvent::DelegatedRotation(p) => { + verify_single_said(raw, &p.said)?; + Ok(KeriEvent::DelegatedRotation(DelegatedRotationEvent::new( + build_rotation(&p)?, + ))) + } + } +} + +pub fn deserialize_inception(raw: &[u8]) -> Result { + let parsed = canonical::parse_inception(raw)?; + verify_inception_said(raw, &parsed)?; + build_inception(&parsed) +} + +pub fn deserialize_rotation(raw: &[u8]) -> Result { + let parsed = canonical::parse_rotation(raw)?; + verify_single_said(raw, &parsed.said)?; + build_rotation(&parsed) +} + +pub fn deserialize_interaction(raw: &[u8]) -> Result { + let parsed = canonical::parse_interaction(raw)?; + verify_single_said(raw, &parsed.said)?; + build_interaction(&parsed) +} + +pub fn deserialize_delegated_inception(raw: &[u8]) -> Result { + let parsed = canonical::parse_delegated_inception(raw)?; + verify_inception_said(raw, &parsed.icp)?; + build_delegated_inception(&parsed) +} + +pub fn deserialize_delegated_rotation(raw: &[u8]) -> Result { + let parsed = canonical::parse_delegated_rotation(raw)?; + verify_single_said(raw, &parsed.said)?; + Ok(DelegatedRotationEvent::new(build_rotation(&parsed)?)) +} +``` + +with builders: + +```rust +fn build_inception(p: &ParsedIcp<'_>) -> Result { + Ok(InceptionEvent::new( + parse_qb64_identifier(p.prefix.value, "i")?, + Seqner::new(parse_sn(p.sn)?), + parse_qb64_diger(p.said.value, "d")?, + verfers_from_parsed(&p.keys, "k")?, + tholder_from_parsed(&p.threshold)?, + digers_from_parsed(&p.next_keys, "n")?, + tholder_from_parsed(&p.next_threshold)?, + prefixers_from_parsed(&p.witnesses, "b")?, + witness_threshold_from_parsed(&p.witness_threshold)?, + config_from_parsed(&p.config)?, + anchors_from_parsed(&p.anchors)?, + )) +} + +fn build_delegated_inception(p: &ParsedDip<'_>) -> Result { + Ok(DelegatedInceptionEvent::new( + build_inception(&p.icp)?, + parse_qb64_identifier(p.delegator, "di")?, + )) +} + +fn build_rotation(p: &ParsedRot<'_>) -> Result { + Ok(RotationEvent::new( + parse_qb64_identifier(p.prefix, "i")?, + Seqner::new(parse_sn(p.sn)?), + parse_qb64_diger(p.said.value, "d")?, + parse_qb64_diger(p.prior, "p")?, + verfers_from_parsed(&p.keys, "k")?, + tholder_from_parsed(&p.threshold)?, + digers_from_parsed(&p.next_keys, "n")?, + tholder_from_parsed(&p.next_threshold)?, + prefixers_from_parsed(&p.witness_additions, "ba")?, + prefixers_from_parsed(&p.witness_removals, "br")?, + witness_threshold_from_parsed(&p.witness_threshold)?, + vec![], + anchors_from_parsed(&p.anchors)?, + )) +} + +fn build_interaction(p: &ParsedIxn<'_>) -> Result { + Ok(InteractionEvent::new( + parse_qb64_identifier(p.prefix, "i")?, + Seqner::new(parse_sn(p.sn)?), + parse_qb64_diger(p.said.value, "d")?, + parse_qb64_diger(p.prior, "p")?, + anchors_from_parsed(&p.anchors)?, + )) +} +``` + +Note the `RotationEvent::new` argument order (additions before removals, then threshold, then `config: vec![]` — match the existing call in the old `deserialize_rotation` exactly; rot events carry no `c` field). + +Remove `use serde_json::Value;` and the now-unused `said_placeholder` import from `deserialize.rs` production imports. + +- [ ] **Step 6.5: Fix knock-on test expectations.** + - In `deserialize.rs` tests: the whitespace-padding probes (`deserialize_*_rejects_length_mismatched_raw`) still expect `InvalidVersionString` — they stay green (the head size check fires first). Keep them. + - In `serialize/direct.rs`: update the comment in `direct_output_verifies_through_unchanged_read_path` — the read path is now the strict parser; the assertion itself is unchanged and still valid. + - Add intive acceptance tests at the public level in `deserialize.rs` tests (replacing the moved Value-level ones), using a re-SAID helper: + +```rust + /// Rewrite the size field and recompute + splice the SAID so a byte-level + /// surgery on a serialized event stays canonical and verifiable. + fn resaid(mut raw: Vec) -> Vec { + use crate::serder::said::{compute_digest, said_placeholder}; + let size = raw.len(); + let hex = format!("{size:06x}"); + raw[16..22].copy_from_slice(hex.as_bytes()); + let d_pos = raw.windows(5).position(|w| w == b"\"d\":\"").unwrap() + 5; + let span = d_pos..d_pos + 44; + let placeholder = said_placeholder(DigestCode::Blake3_256).unwrap(); + let mut scratch = raw.clone(); + scratch[span.clone()].copy_from_slice(placeholder.as_bytes()); + let computed = compute_digest(&scratch, DigestCode::Blake3_256).unwrap(); + let qb64 = crate::serder::primitives::to_qb64_string(&computed); + raw[span].copy_from_slice(qb64.as_bytes()); + raw + } + + #[test] + fn intive_integer_bt_is_accepted() { + let raw = serialize_inception(&probe_icp()).unwrap().as_bytes().to_vec(); + let pos = raw.windows(9).position(|w| w == b"\"bt\":\"0\",").unwrap(); + let mut mutated = Vec::with_capacity(raw.len()); + mutated.extend_from_slice(&raw[..pos]); + mutated.extend_from_slice(b"\"bt\":0,"); + mutated.extend_from_slice(&raw[pos + 9..]); + let canonical_intive = resaid(mutated); + let event = deserialize_inception(&canonical_intive) + .expect("keripy intive=True integer bt must deserialize"); + assert_eq!(event.witness_threshold(), 0); + } + + #[test] + fn intive_integer_kt_is_accepted() { + let raw = serialize_inception(&probe_icp()).unwrap().as_bytes().to_vec(); + let pos = raw.windows(9).position(|w| w == b"\"kt\":\"1\",").unwrap(); + let mut mutated = Vec::with_capacity(raw.len()); + mutated.extend_from_slice(&raw[..pos]); + mutated.extend_from_slice(b"\"kt\":1,"); + mutated.extend_from_slice(&raw[pos + 9..]); + let canonical_intive = resaid(mutated); + let event = deserialize_inception(&canonical_intive) + .expect("keripy intive=True integer kt must deserialize"); + assert_eq!(*event.threshold(), Tholder::Simple(1)); + } +``` + + (Note: `resaid` performs single-SAID recomputation, which is only valid for icp probes when `d != i`; `probe_icp()` uses a `make_prefixer()` basic prefix, so `d != i` holds and single-SAID applies.) + + - Behavior-change probes (new): + +```rust + #[test] + fn deserialize_rotation_rejects_drt_bytes() { + let drt = DelegatedRotationEvent::new(probe_rot()); + let raw = serialize_delegated_rotation(&drt).unwrap(); + assert!(matches!( + deserialize_rotation(raw.as_bytes()), + Err(SerderError::NonCanonical { expected: "rot", .. }) + )); + } + + #[test] + fn deserialize_inception_rejects_dip_bytes() { + let dip = DelegatedInceptionEvent::new(probe_icp(), make_prefixer().into()); + let raw = serialize_delegated_inception(&dip).unwrap(); + assert!( + deserialize_inception(raw.as_bytes()).is_err(), + "dip bytes must not silently deserialize as icp (delegator dropped)" + ); + } +``` + +- [ ] **Step 6.6: Run the full crate test suite** — the existing round-trip, tamper, and version-string suites are the regression net for the rewire: + +Run: `nix develop --command cargo nextest run -p cesr-rs` +Expected: all PASS. Also run doctests: `nix develop --command cargo test -p cesr-rs --doc` — expected PASS. + +- [ ] **Step 6.7: Commit** + +```bash +git add cesr/src/serder/deserialize.rs cesr/src/serder/deserialize/reference.rs cesr/src/serder/serialize/direct.rs +git commit -m "feat(serder)!: rewire deserializers onto the strict canonical parser (#142)" +``` + +--- + +### Task 7: `said::verify_said` — strict, `Result<(), SerderError>` + +**Files:** +- Modify: `cesr/src/serder/said.rs` +- Modify (if it pins the old signature): `cesr/tests/frozen_surface.rs` + +- [ ] **Step 7.1: Replace the implementation** (keep the name; update docs): + +```rust +/// Verify that the `d` field of a serialized canonical event matches a +/// freshly computed SAID. +/// +/// Parses the event with the strict canonical parser, fills the `d` (and, +/// for `icp`/`dip` events whose prefix equals their SAID, the `i`) value +/// span with [`DUMMY_CHAR`] in a single scratch copy, hashes, and compares. +/// +/// # Errors +/// +/// Returns [`SerderError::SaidMismatch`] if the digest differs, +/// [`SerderError::NonCanonical`] / [`SerderError::InvalidVersionString`] if +/// the input is not a canonical event, or [`SerderError::DigestError`] on +/// hash failure. +pub fn verify_said(raw: &[u8], code: DigestCode) -> Result<(), SerderError> { + match parse_event(raw)? { + ParsedEvent::Inception(p) | ParsedEvent::DelegatedInception(ParsedDip { icp: p, .. }) => { + let prefix_span = (p.said.value == p.prefix.value).then_some(&p.prefix.span); + verify_said_spans(raw, p.said.value, &p.said.span, prefix_span, code) + } + ParsedEvent::Rotation(p) | ParsedEvent::DelegatedRotation(p) => { + verify_said_spans(raw, p.said.value, &p.said.span, None, code) + } + ParsedEvent::Interaction(p) => { + verify_said_spans(raw, p.said.value, &p.said.span, None, code) + } + } +} +``` + +with the import at the top of `said.rs`: + +```rust +use crate::serder::deserialize::canonical::{ParsedDip, ParsedEvent, parse_event}; +``` + +Remove the old body's `serde_json` usage from `said.rs` entirely. + +- [ ] **Step 7.2: Update every caller.** + +Run: `rg -n "verify_said\(" cesr/ keri/ --glob '!**/canonical.rs'` +For each hit (tests in `said.rs`, possibly `frozen_surface.rs`): change `assert!(verify_said(...) .unwrap())` forms to `verify_said(...).expect(...)` / `assert!(matches!(verify_said(...), Err(SerderError::SaidMismatch { .. })))`. + +- [ ] **Step 7.3: Add/adjust tests in `said.rs`:** + +```rust + #[test] + fn verify_said_accepts_serialized_event() { + let (raw, _) = probe_ixn_raw(); + verify_said(&raw, DigestCode::Blake3_256).expect("writer output must verify"); + } + + #[test] + fn verify_said_rejects_tampered_event() { + let (mut raw, _) = probe_ixn_raw(); + let s_pos = raw.windows(8).position(|w| w == b",\"s\":\"1\"").unwrap(); + raw[s_pos + 6] = b'2'; + assert!(matches!( + verify_said(&raw, DigestCode::Blake3_256), + Err(SerderError::SaidMismatch { .. }) + )); + } + + #[test] + fn verify_said_rejects_non_canonical_input() { + assert!(matches!( + verify_said(b"not an event", DigestCode::Blake3_256), + Err(SerderError::NonCanonical { .. }) | Err(SerderError::InvalidVersionString(_)) + )); + } +``` + +- [ ] **Step 7.4: Run tests** + +Run: `nix develop --command cargo nextest run -p cesr-rs` +Expected: PASS. + +- [ ] **Step 7.5: Commit** + +```bash +git add cesr/src/serder/said.rs cesr/tests/frozen_surface.rs +git commit -m "feat(serder)!: verify_said goes strict and returns Result<(), SerderError> (#142)" +``` + +--- + +### Task 8: Extract shared proptest event strategies + +**Files:** +- Create: `cesr/src/serder/event_strategies.rs` +- Modify: `cesr/src/serder/mod.rs`, `cesr/src/serder/serialize/direct.rs` + +- [ ] **Step 8.1: Move the strategy layer.** Cut from `serialize/direct.rs`'s `mod tests` into the new file: `prefixer`, `saider`, the spec type aliases (`IdSpec`, `SealSpec`, `TholderSpec`, `IcpSpec`, `RotSpec`, `IxnSpec`), the `build_*` functions (`build_identifier`, `build_seal`, `build_tholder`, `build_config`, `build_icp`, `build_rot`, `build_ixn`), and the `*_strategy()` functions (`sn_strategy`, `bt_strategy`, `tholder_strategy`, `seal_strategy`, `icp_strategy`, `rot_strategy`, `ixn_strategy`). File header: + +```rust +//! Shared proptest strategies over the builder-reachable KERI event space. +//! +//! Single source of truth for cross-backend (write path) and +//! strict-vs-reference (read path) differential property tests. + +use crate::core::matter::builder::MatterBuilder; +use crate::core::matter::code::{DigestCode, VerKeyCode}; +use crate::core::primitives::{Prefixer, Saider, Seqner, Tholder}; +use crate::keri::{ConfigTrait, Identifier, InceptionEvent, InteractionEvent, RotationEvent, Seal}; +use alloc::borrow::Cow; +use alloc::{vec, vec::Vec}; +use proptest::prelude::*; +``` + +All items become `pub(crate)`. Register in `serder/mod.rs`: + +```rust +#[cfg(test)] +pub(crate) mod event_strategies; +``` + +- [ ] **Step 8.2: Re-point `serialize/direct.rs` tests** at the shared module: + +```rust + use crate::serder::event_strategies::{ + IdSpec, build_icp, build_identifier, build_ixn, build_rot, icp_strategy, ixn_strategy, + prefixer, rot_strategy, saider, + }; +``` + +and delete the moved definitions from `direct.rs`. Everything else in the file is untouched. + +- [ ] **Step 8.3: Run the write-path differentials to prove the move is behavior-neutral** + +Run: `nix develop --command cargo nextest run -p cesr-rs direct::` +Expected: PASS, same test count as before the move. + +- [ ] **Step 8.4: Commit** + +```bash +git add cesr/src/serder/event_strategies.rs cesr/src/serder/mod.rs cesr/src/serder/serialize/direct.rs +git commit -m "refactor(serder): extract shared event proptest strategies (#142)" +``` + +--- + +### Task 9: Read-path differential and mutation properties + +**Files:** +- Modify: `cesr/src/serder/deserialize.rs` (tests module) + +- [ ] **Step 9.1: Differential property — strict and reference agree over the builder space, and both round-trip byte-identically.** Domain events deliberately have no `PartialEq`, so equality is asserted via re-serialization bytes: + +```rust + mod differential { + use super::super::reference; + use super::*; + use crate::serder::event_strategies::{ + IdSpec, build_icp, build_identifier, build_ixn, build_rot, icp_strategy, + ixn_strategy, rot_strategy, + }; + use crate::serder::serialize::{EventRef, SerdeJson, serialize_with}; + use proptest::prelude::*; + + proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + #[test] + fn icp_strict_equals_reference(spec in icp_strategy()) { + let event = build_icp(spec); + let bytes = serialize_with(&SerdeJson, EventRef::Inception(&event)).unwrap(); + let strict = deserialize_inception(bytes.as_bytes()).unwrap(); + let oracle = reference::deserialize_inception(bytes.as_bytes()).unwrap(); + let strict_bytes = serialize_inception(&strict).unwrap(); + let oracle_bytes = serialize_inception(&oracle).unwrap(); + prop_assert_eq!(strict_bytes.as_bytes(), oracle_bytes.as_bytes()); + prop_assert_eq!(strict_bytes.as_bytes(), bytes.as_bytes()); + } + + #[test] + fn rot_strict_equals_reference(spec in rot_strategy()) { + let event = build_rot(spec); + let bytes = serialize_with(&SerdeJson, EventRef::Rotation(&event)).unwrap(); + let strict = deserialize_rotation(bytes.as_bytes()).unwrap(); + let oracle = reference::deserialize_rotation(bytes.as_bytes()).unwrap(); + let strict_bytes = serialize_rotation(&strict).unwrap(); + let oracle_bytes = serialize_rotation(&oracle).unwrap(); + prop_assert_eq!(strict_bytes.as_bytes(), oracle_bytes.as_bytes()); + prop_assert_eq!(strict_bytes.as_bytes(), bytes.as_bytes()); + } + + #[test] + fn ixn_strict_equals_reference(spec in ixn_strategy()) { + let event = build_ixn(spec); + let bytes = serialize_with(&SerdeJson, EventRef::Interaction(&event)).unwrap(); + let strict = deserialize_interaction(bytes.as_bytes()).unwrap(); + let oracle = reference::deserialize_interaction(bytes.as_bytes()).unwrap(); + let strict_bytes = serialize_interaction(&strict).unwrap(); + let oracle_bytes = serialize_interaction(&oracle).unwrap(); + prop_assert_eq!(strict_bytes.as_bytes(), oracle_bytes.as_bytes()); + prop_assert_eq!(strict_bytes.as_bytes(), bytes.as_bytes()); + } + + #[test] + fn dip_strict_equals_reference(spec in icp_strategy(), delegator in any::()) { + let dip = DelegatedInceptionEvent::new(build_icp(spec), build_identifier(delegator)); + let bytes = serialize_with(&SerdeJson, EventRef::DelegatedInception(&dip)).unwrap(); + let strict = deserialize_delegated_inception(bytes.as_bytes()).unwrap(); + let oracle = reference::deserialize_delegated_inception(bytes.as_bytes()).unwrap(); + let strict_bytes = serialize_delegated_inception(&strict).unwrap(); + let oracle_bytes = serialize_delegated_inception(&oracle).unwrap(); + prop_assert_eq!(strict_bytes.as_bytes(), oracle_bytes.as_bytes()); + prop_assert_eq!(strict_bytes.as_bytes(), bytes.as_bytes()); + } + + #[test] + fn drt_strict_equals_reference(spec in rot_strategy()) { + let drt = DelegatedRotationEvent::new(build_rot(spec)); + let bytes = serialize_with(&SerdeJson, EventRef::DelegatedRotation(&drt)).unwrap(); + let strict = deserialize_delegated_rotation(bytes.as_bytes()).unwrap(); + let oracle = reference::deserialize_delegated_rotation(bytes.as_bytes()).unwrap(); + let strict_bytes = serialize_delegated_rotation(&strict).unwrap(); + let oracle_bytes = serialize_delegated_rotation(&oracle).unwrap(); + prop_assert_eq!(strict_bytes.as_bytes(), oracle_bytes.as_bytes()); + prop_assert_eq!(strict_bytes.as_bytes(), bytes.as_bytes()); + } + + /// Strict acceptance is a subset of tolerant acceptance: any + /// single-byte mutation the strict parser accepts, the reference + /// oracle must also accept — and both must see the same event. + #[test] + fn strict_acceptance_is_subset_of_reference( + spec in ixn_strategy(), + idx in any::(), + byte in any::(), + ) { + let event = build_ixn(spec); + let bytes = serialize_with(&SerdeJson, EventRef::Interaction(&event)).unwrap(); + let mut mutated = bytes.as_bytes().to_vec(); + let i = idx.index(mutated.len()); + mutated[i] = byte; + if let Ok(strict) = deserialize_interaction(&mutated) { + let oracle = reference::deserialize_interaction(&mutated); + prop_assert!( + oracle.is_ok(), + "strict accepted a mutation the tolerant oracle rejects" + ); + let strict_bytes = serialize_interaction(&strict).unwrap(); + let oracle_bytes = serialize_interaction(&oracle.unwrap()).unwrap(); + prop_assert_eq!(strict_bytes.as_bytes(), oracle_bytes.as_bytes()); + } + } + } + } +``` + +- [ ] **Step 9.2: Run the differentials** + +Run: `nix develop --command cargo nextest run -p cesr-rs differential` +Expected: PASS (any divergence between strict and oracle is a bug in Task 4/6 — debug there, do not weaken the property). + +- [ ] **Step 9.3: Commit** + +```bash +git add cesr/src/serder/deserialize.rs +git commit -m "test(serder): strict-vs-reference read-path differential and mutation-subset properties (#142)" +``` + +--- + +### Task 10: Allocation pin + +**Files:** +- Modify: `cesr/tests/serder_allocation.rs` + +- [ ] **Step 10.1: Add the deserialize measurement** (uses the existing `measure` + `fixture_icp` helpers in that file): + +```rust +use cesr::serder::deserialize_event; + +#[test] +fn deserialize_allocation_count_is_pinned() { + let event = fixture_icp(); + let serialized = serialize_with(&DirectJson, EventRef::Inception(&event)).unwrap(); + let bytes = serialized.as_bytes(); + + let _ = deserialize_event(bytes).unwrap(); + + let (parsed, allocs) = measure(|| deserialize_event(bytes).unwrap()); + drop(parsed); + + assert_eq!( + allocs, DESERIALIZE_ALLOCS, + "deserialize_event allocation count changed — the strict read path \ + must stay at one scratch copy plus domain-type construction; a rise \ + means an intermediate tree or render crept back in" + ); +} +``` + +- [ ] **Step 10.2: Measure the true count, then pin it.** First run with `const DESERIALIZE_ALLOCS: usize = 0;` — the failure output prints the measured value. Set the constant to that exact value with a comment decomposing it (1 scratch copy + N domain Vec/String allocations for the fixture). + +Run: `nix develop --command cargo nextest run -p cesr-rs deserialize_allocation` +Expected: FAIL once (prints real count), then PASS after pinning. + +- [ ] **Step 10.3: Commit** + +```bash +git add cesr/tests/serder_allocation.rs +git commit -m "test(serder): pin strict read-path allocation count (#142)" +``` + +--- + +### Task 11: Deserialize benchmark + +**Files:** +- Modify: `cesr/benches/serder.rs` + +- [ ] **Step 11.1: Add the bench** (reuses the existing `fixture_icp`; check the file's existing `criterion_group!` and add the new function to it): + +```rust +use cesr::serder::deserialize_event; + +fn bench_deserialize(c: &mut Criterion) { + let icp = fixture_icp(); + let serialized = serialize_with(&SerdeJson, EventRef::Inception(&icp)).expect("fixture serializes"); + let bytes = serialized.as_bytes(); + c.bench_function("deserialize_event/icp", |b| { + b.iter(|| deserialize_event(black_box(bytes)).expect("fixture deserializes")); + }); +} +``` + +and extend the group at the bottom of the file, e.g. `criterion_group!(benches, bench_serialize, bench_deserialize);` (match the existing group name/members exactly — read the tail of the file first). + +- [ ] **Step 11.2: Smoke-run the bench compilation** + +Run: `nix develop --command cargo bench -p cesr-rs --bench serder -- --test` +Expected: compiles and runs each bench once, exit 0. + +- [ ] **Step 11.3: Commit** + +```bash +git add cesr/benches/serder.rs +git commit -m "bench(serder): deserialize_event throughput for CodSpeed (#142)" +``` + +--- + +### Task 12: Fuzz targets (bolero + AFL) + +**Files:** +- Modify: `fuzz-common/Cargo.toml`, `fuzz-common/src/lib.rs` +- Create: `fuzz/tests/serder.rs`, `fuzz-afl/src/bin/serder_deserialize_event.rs` +- Modify: `fuzz-afl/Cargo.toml`, possibly `fuzz/` Cargo manifest and the deep-fuzz workflow + +- [ ] **Step 12.1: fuzz-common** — enable serder and add the harness body: + +```toml +cesr = { package = "cesr-rs", path = "../cesr", features = ["stream", "serder"] } +``` + +```rust +use cesr::serder::{KeriSerialize, deserialize_event}; + +/// Strict canonical event parse on untrusted bytes. A panic is a finding. +/// If the input parses, it must re-serialize and re-parse cleanly +/// (idempotence); byte-identity is NOT asserted because keripy intive +/// integers legally re-render as hex strings. +pub fn serder_deserialize_event(data: &[u8]) { + if let Ok(event) = deserialize_event(data) { + let reser = event + .serialize() + .expect("a parsed event must re-serialize"); + deserialize_event(reser.as_bytes()) + .expect("a re-serialized event must re-parse"); + } +} +``` + +- [ ] **Step 12.2: bolero target** — `fuzz/tests/serder.rs` (mirror `binary.rs`'s shape): + +```rust +//! Fuzz target for strict canonical KERI event deserialization. + +#[test] +fn serder_deserialize_event() { + bolero::check!().for_each(|input: &[u8]| fuzz_common::serder_deserialize_event(input)); +} +``` + +- [ ] **Step 12.3: AFL target** — `fuzz-afl/src/bin/serder_deserialize_event.rs` (mirror an existing bin, e.g. `matter_from_qb64.rs` — read it first and copy its exact shape), plus the manifest entry: + +```toml +[[bin]] +name = "serder_deserialize_event" +path = "src/bin/serder_deserialize_event.rs" +``` + +(match the `path`/formatting convention of the existing `[[bin]]` entries exactly). + +- [ ] **Step 12.4: Wire into CI target lists if enumerated.** + +Run: `rg -n "stream_parse_message|matter_from_qb64" .github/workflows/ flake.nix nix/` +If the deep-fuzz workflow or the `cesr-fuzz-replay` check enumerates targets explicitly, add `serder_deserialize_event` in the same style at each site. + +- [ ] **Step 12.5: Smoke both engines locally** (bolero runs as a plain test without a fuzzing engine; AFL is CI-only on macOS per project memory — compile-check it only): + +```bash +nix develop --command bash -c "cd fuzz && cargo test serder_deserialize_event" +nix develop --command bash -c "cd fuzz-afl && cargo check" +``` + +Expected: both exit 0. + +- [ ] **Step 12.6: Commit** + +```bash +git add fuzz-common/ fuzz/tests/serder.rs fuzz-afl/ .github/workflows/ 2>/dev/null +git commit -m "ci(fuzz): strict serder deserialize fuzz target for both engines (#142)" +``` + +--- + +### Task 13: Docs, gate, PR + +**Files:** +- Modify: `cesr/src/serder/mod.rs`, `cesr/src/serder/deserialize.rs` (module docs), `cesr/CHANGELOG.md` (only if release-plz does not auto-generate — check recent breaking PRs' handling first) + +- [ ] **Step 13.1: Update module docs.** `deserialize.rs` header: + +```rust +//! KERI event deserialization from canonical JSON with SAID verification. +//! +//! The read path is a strict single-pass canonical parser +//! ([`canonical`]): compact JSON, spec field order, no escapes — any +//! deviation is a typed [`SerderError::NonCanonical`]. SAID verification +//! is offset-based: one scratch copy of the raw bytes, the `d` (and `i` +//! for `icp`/`dip`) spans overwritten with `#`, one hash — no +//! parse-mutate-re-render. +``` + +and adjust the `serder/mod.rs` crate-module doc's deserialization sentence to say "strict canonical parsing with in-place SAID verification". + +- [ ] **Step 13.2: The single gate** (commit everything first — the flake check sees only committed state): + +```bash +git status --short # must be empty +nix flake check +``` + +Expected: all 36 checks green, including `cesr-wasm`, `cesr-nostd`, `cesr-clippy` (god-level), nextest across the feature matrix, and the keri crate's differential suite (keripy corpus through the now-strict `deserialize_event`). + +- [ ] **Step 13.3: Push and open the PR** + +```bash +git push -u origin feat/142-strict-read-path +gh pr create --title "feat(serder)!: #142 strict canonical read-path parser — offset-based SAID verification" --body "$(cat <<'EOF' +Closes #142. The read-path half of the #79 seam (design §3.3/§3.5). + +## What +- Strict single-pass canonical parser for the five fixed event grammars + (`serder::deserialize::canonical`): compact JSON, spec field order, no + escapes/duplicates/whitespace — rejected by construction with the new + typed `SerderError::NonCanonical { offset, expected, found }`. +- Offset-based SAID verification: one scratch copy + span fill + hash, + replacing 2–3 `Value` trees and a full re-render per ingested event. +- Borrowed `&str` field views (`ParsedIcp<'a>` …) — the substrate for + C-a #129's borrow-ified `KeriEvent<'a>`. +- keripy `intive=True` integer `kt`/`nt`/`bt` still accepted (their SAIDs + are computed over the integer form; rejecting them is a parity gap). + +## Breaking (MINOR under 0.x) +- New `SerderError::NonCanonical` variant; non-canonical inputs now fail + with it instead of assorted `Json`/`MissingField` errors. +- `said::verify_said` is strict and returns `Result<(), SerderError>` + (was `Result`). +- Per-ilk deserializers require their exact ilk (`deserialize_rotation` + no longer accepts `drt` bytes; `deserialize_inception` no longer + silently accepts `dip` bytes and drops the delegator). + +## Verification +- Strict-vs-reference differential proptests over the builder-reachable + event space (all five ilks), byte-identical re-serialization. +- Mutation-subset property: anything strict accepts, the tolerant oracle + accepts identically. +- Rejection probes: whitespace (size-consistent), duplicate keys, + reordered fields, escapes, trailing bytes, every-prefix truncation. +- keripy corpus through the strict path via keri/tests/differential.rs. +- Allocation count pinned; CodSpeed deserialize bench added; bolero+AFL + fuzz target `serder_deserialize_event` (idempotence oracle). +- `nix flake check` green (wasm + no_std included). + +The old `serde_json::Value` read path survives only as the `#[cfg(test)]` +differential oracle (`deserialize::reference`). serde demotion to +dev-dependencies is the follow-up card per design §6 staging. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 13.4: Attach the PR/issue to the project board** (org Project #5) and note CodSpeed numbers in the PR once CI reports them. + +--- + +## Self-Review Notes (done at plan time) + +- **Spec coverage:** issue acceptance ↔ tasks: strict parser behind EventLayout-style slot vocabulary (Tasks 2–5), byte-range SAID verify without re-render (Task 5), differential vs serde_json read path over builder space (Task 9) + keripy corpus (keri differential suite, Step 13.2), rejection tests for whitespace/duplicates/reordering/escapes (Task 4), no_std+wasm+flake (Step 13.2). +- **Order-of-checks invariant:** head size check (#139 defence) fires before grammar; grammar fires before SAID verify; SAID verify fires before qb64 domain conversion — tests in Tasks 4/6 pin each layer. +- **Type consistency:** `Spanned`/`ParsedIcp`/`ParsedDip`/`ParsedRot`/`ParsedIxn`/`ParsedEvent`/`ParsedTholder`/`ParsedCount`/`ParsedSeal` are defined once in Task 2–4 and used with those exact names in Tasks 6–7; `verify_said_spans(raw, said_value, said_span, prefix_span, code)` signature is identical in Tasks 5, 6, and 7. +- **Known judgment calls an executor must not "fix" silently:** integer acceptance for `kt`/`nt`/`bt` is deliberate (keripy intive parity); `RotationEvent` grammar has **no** `c` field; double-SAID fill only when `d == i` (matches the tolerant path's semantics); fuzz round-trip asserts idempotence, not byte identity (intive re-renders as hex). From bc684b26e1568a2e0363bb91b35c45533ccffafb Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sat, 11 Jul 2026 00:02:37 +0200 Subject: [PATCH 19/21] fix(docs): correct scanner test literal in plan doc to match source (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typos gate flagged "abd" as a misspelling of "and"/"bad"; the actual source test in canonical.rs uses "abX" for this mismatch scenario — fix the plan doc's transcription to match. Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-10-142-strict-canonical-read-path.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-07-10-142-strict-canonical-read-path.md b/docs/superpowers/plans/2026-07-10-142-strict-canonical-read-path.md index 460fb44..ed8b138 100644 --- a/docs/superpowers/plans/2026-07-10-142-strict-canonical-read-path.md +++ b/docs/superpowers/plans/2026-07-10-142-strict-canonical-read-path.md @@ -338,7 +338,7 @@ mod tests { #[test] fn scanner_expect_reports_offset_and_found() { let mut sc = Scanner::new(b"abc"); - let err = sc.expect("abd").unwrap_err(); + let err = sc.expect("abX").unwrap_err(); assert!(matches!( err, SerderError::NonCanonical { From 40e520cab88151b112848fe10fd924d40934d8c0 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sat, 11 Jul 2026 00:59:38 +0200 Subject: [PATCH 20/21] test(serder): deterministic per-variant coverage matrix for the strict read path (#142) Pins every ParsedSeal/Tholder/Count/Identifier variant, both SAID branches (single d!=i and double d==i), all five ilk-dispatch arms, and each reachable read-path error variant through strict==oracle==roundtrip, replacing probabilistic proptest coverage of these variants with guaranteed deterministic checks. Co-Authored-By: Claude Fable 5 --- cesr/src/serder/deserialize.rs | 618 +++++++++++++++++++++++++++++++++ 1 file changed, 618 insertions(+) diff --git a/cesr/src/serder/deserialize.rs b/cesr/src/serder/deserialize.rs index f3d3f7d..8819f30 100644 --- a/cesr/src/serder/deserialize.rs +++ b/cesr/src/serder/deserialize.rs @@ -1451,4 +1451,622 @@ mod tests { } } } + + // ----------------------------------------------------------------------- + // Deterministic per-variant coverage matrix (#142, Task 15). + // + // The `differential` proptests above hit enum variants by RANDOM draw. + // This module pins EVERY variant and EVERY SAID branch explicitly, once, + // deterministically: each builds a canonical serialized event embedding + // exactly one variant, then asserts strict == oracle == original-bytes + // AND pattern-matches the specific parsed arm. This is the guaranteed + // complement to the probabilistic differential suite. + // ----------------------------------------------------------------------- + mod variant_matrix { + use super::super::reference; + use super::*; + + // Per-ilk equivalence helpers: assert strict accepts, oracle accepts, + // and both re-serialize to each other and to the original bytes. + // Return the strict-parsed event so the caller can pin its variant. + + fn ixn_strict_eq_oracle(bytes: &[u8]) -> InteractionEvent { + let strict = deserialize_interaction(bytes).expect("strict must accept"); + let oracle = reference::deserialize_interaction(bytes).expect("oracle must accept"); + let sb = serialize_interaction(&strict).unwrap(); + let ob = serialize_interaction(&oracle).unwrap(); + assert_eq!(sb.as_bytes(), ob.as_bytes(), "strict vs oracle divergence"); + assert_eq!( + sb.as_bytes(), + bytes, + "re-serialization must reproduce original" + ); + strict + } + + fn icp_strict_eq_oracle(bytes: &[u8]) -> InceptionEvent { + let strict = deserialize_inception(bytes).expect("strict must accept"); + let oracle = reference::deserialize_inception(bytes).expect("oracle must accept"); + let sb = serialize_inception(&strict).unwrap(); + let ob = serialize_inception(&oracle).unwrap(); + assert_eq!(sb.as_bytes(), ob.as_bytes(), "strict vs oracle divergence"); + assert_eq!( + sb.as_bytes(), + bytes, + "re-serialization must reproduce original" + ); + strict + } + + fn ixn_with_anchor(seal: Seal) -> Vec { + let event = InteractionEvent::new( + make_prefixer().into(), + Seqner::new(2), + make_saider(), + make_saider(), + vec![seal], + ); + serialize_interaction(&event).unwrap().as_bytes().to_vec() + } + + fn icp_with_kt(kt: Tholder, key_count: usize) -> Vec { + let keys: Vec> = (0..key_count).map(|_| make_verfer()).collect(); + let event = InceptionEvent::new( + make_prefixer().into(), + Seqner::new(0), + make_saider(), + keys, + kt, + vec![make_diger()], + Tholder::Simple(1), + vec![], + 0, + vec![], + vec![], + ); + serialize_inception(&event).unwrap().as_bytes().to_vec() + } + + // ------------------------------------------------------------------- + // Matrix A — every `ParsedSeal` / `seal_from_parsed` arm (all 5), + // driven through the ixn `a` array, one deterministic seal per test. + // ------------------------------------------------------------------- + + #[test] + fn seal_digest_variant_is_pinned() { + let bytes = ixn_with_anchor(Seal::Digest { d: make_saider() }); + let strict = ixn_strict_eq_oracle(&bytes); + assert!(matches!(strict.anchors()[0], Seal::Digest { .. })); + } + + #[test] + fn seal_root_variant_is_pinned() { + let bytes = ixn_with_anchor(Seal::Root { rd: make_saider() }); + let strict = ixn_strict_eq_oracle(&bytes); + assert!(matches!(strict.anchors()[0], Seal::Root { .. })); + } + + #[test] + fn seal_source_variant_is_pinned() { + let bytes = ixn_with_anchor(Seal::Source { + s: Seqner::new(5), + d: make_saider(), + }); + let strict = ixn_strict_eq_oracle(&bytes); + let Seal::Source { s, .. } = &strict.anchors()[0] else { + unreachable!("expected Source seal") + }; + assert_eq!(s.value(), 5); + } + + #[test] + fn seal_event_variant_is_pinned() { + let bytes = ixn_with_anchor(Seal::Event { + i: make_prefixer(), + s: Seqner::new(0xff), + d: make_saider(), + }); + let strict = ixn_strict_eq_oracle(&bytes); + let Seal::Event { s, .. } = &strict.anchors()[0] else { + unreachable!("expected Event seal") + }; + assert_eq!(s.value(), 0xff); + } + + #[test] + fn seal_last_variant_is_pinned() { + let bytes = ixn_with_anchor(Seal::Last { i: make_prefixer() }); + let strict = ixn_strict_eq_oracle(&bytes); + assert!(matches!(strict.anchors()[0], Seal::Last { .. })); + } + + // ------------------------------------------------------------------- + // Matrix B — Identifier prefix + SAID single/double, both branches + // of `verify_inception_said`'s `d == i` gate. + // ------------------------------------------------------------------- + + /// Splice a genuine basic-derivation prefix into the `i` field of a + /// canonical icp, then re-SAID single-SAID (only `d` placeholdered). + /// The write path ALWAYS forces `i == d` (double-SAID) for icp/dip + /// (`EventRef::is_double_said`), so a single-SAID (d != i) icp is not + /// reachable through `serialize_inception`; byte surgery is the only + /// way to construct one, and `super::resaid` recomputes the + /// single-SAID form. + fn splice_basic_prefix_icp() -> Vec { + let mut raw = serialize_inception(&probe_icp()) + .unwrap() + .as_bytes() + .to_vec(); + // A basic Ed25519 prefix is 44 qb64 chars, exactly the width of a + // Blake3_256 SAID, so the `i` span width is preserved. + let basic = crate::serder::primitives::to_qb64_string(&make_prefixer()); + assert_eq!(basic.len(), 44, "basic prefix must be 44 qb64 chars"); + let i_key = raw.windows(6).position(|w| w == b",\"i\":\"").unwrap(); + let i_val = i_key + 6; + raw[i_val..i_val + 44].copy_from_slice(basic.as_bytes()); + super::resaid(raw) + } + + /// single-SAID (d != i): a basic-derivation prefix. Exercises the + /// FALSE branch of `verify_inception_said` (only `d` placeholdered). + /// + /// The write path is lossy for a single-SAID icp — re-serializing + /// forces `i == d` (double-SAID) again — so this cannot assert + /// re-serialization reproduces the spliced original. It asserts + /// instead that strict and oracle build the SAME event (identical + /// re-serialization to each other), plus the Basic-prefix arm. + #[test] + fn identifier_basic_single_said_is_pinned() { + let bytes = splice_basic_prefix_icp(); + let strict = deserialize_inception(&bytes).expect("strict must accept"); + let oracle = reference::deserialize_inception(&bytes).expect("oracle must accept"); + let sb = serialize_inception(&strict).unwrap(); + let ob = serialize_inception(&oracle).unwrap(); + assert_eq!(sb.as_bytes(), ob.as_bytes(), "strict vs oracle divergence"); + assert!(matches!(strict.prefix(), Identifier::Basic(_))); + // d != i for a basic prefix: the SAID and the prefix differ. + let said_qb64 = qb64(strict.said()); + let prefix_qb64 = crate::serder::primitives::identifier_to_qb64_string(strict.prefix()); + assert_ne!(said_qb64, prefix_qb64, "basic prefix must differ from SAID"); + } + + /// double-SAID (d == i): a self-addressing inception where prefix == + /// said, produced by the write path's `InceptionBuilder`. Exercises + /// the TRUE branch of `verify_inception_said` (both `d` and `i` + /// placeholdered) — today hit only by chance in the differential. + #[test] + fn identifier_self_addressing_double_said_is_pinned() { + use crate::serder::builder::icp::InceptionBuilder; + + let built = InceptionBuilder::new() + .keys(vec![make_verfer()]) + .build() + .unwrap(); + let bytes = built.as_bytes().to_vec(); + let strict = icp_strict_eq_oracle(&bytes); + assert!(matches!(strict.prefix(), Identifier::SelfAddressing(_))); + assert_eq!( + strict.prefix().as_saider().unwrap().raw(), + strict.said().raw(), + "self-addressing prefix raw bytes must equal SAID raw bytes" + ); + } + + /// dip with a basic (single-SAID) prefix, spliced the same way as the + /// icp single-SAID case (the write path forces `i == d` here too). + /// The double-SAID dip path shares `verify_inception_said` with the + /// icp double case: `deserialize_delegated_inception` calls the same + /// `verify_inception_said` over `p.icp`, so the double (TRUE) branch + /// is covered structurally by + /// `identifier_self_addressing_double_said_is_pinned`; this pins the + /// single (FALSE) branch reaching the dip build path. + #[test] + fn dip_basic_single_said_is_pinned() { + let dip = DelegatedInceptionEvent::new(probe_icp(), make_prefixer().into()); + let mut raw = serialize_delegated_inception(&dip) + .unwrap() + .as_bytes() + .to_vec(); + let basic = crate::serder::primitives::to_qb64_string(&make_prefixer()); + let i_key = raw.windows(6).position(|w| w == b",\"i\":\"").unwrap(); + let i_val = i_key + 6; + raw[i_val..i_val + 44].copy_from_slice(basic.as_bytes()); + let bytes = super::resaid(raw); + + let strict = deserialize_delegated_inception(&bytes).expect("strict must accept"); + let oracle = + reference::deserialize_delegated_inception(&bytes).expect("oracle must accept"); + let sb = serialize_delegated_inception(&strict).unwrap(); + let ob = serialize_delegated_inception(&oracle).unwrap(); + // Write path is lossy for a single-SAID dip (re-forces i == d), so + // assert strict/oracle agreement only, not reproduction of the + // spliced original. + assert_eq!(sb.as_bytes(), ob.as_bytes(), "strict vs oracle divergence"); + assert!(matches!(strict.inception().prefix(), Identifier::Basic(_))); + } + + // ------------------------------------------------------------------- + // Matrix C — every `ParsedTholder` rendering through kt. + // ------------------------------------------------------------------- + + #[test] + fn tholder_simple_one_is_pinned() { + let bytes = icp_with_kt(Tholder::Simple(1), 1); + // kt renders as hex: 1 -> "1". + let json: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(json["kt"].as_str().unwrap(), "1"); + let strict = icp_strict_eq_oracle(&bytes); + assert_eq!(*strict.threshold(), Tholder::Simple(1)); + } + + #[test] + fn tholder_simple_ten_renders_hex_not_decimal() { + let bytes = icp_with_kt(Tholder::Simple(10), 10); + // Hex-not-decimal: 10 -> "a", never "10". + let json: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(json["kt"].as_str().unwrap(), "a"); + let strict = icp_strict_eq_oracle(&bytes); + assert_eq!(*strict.threshold(), Tholder::Simple(10)); + } + + #[test] + fn tholder_weighted_single_clause_is_flat_array() { + let expected = Tholder::Weighted(vec![vec![(1, 2), (1, 2)]]); + let bytes = icp_with_kt(expected.clone(), 2); + // Single clause flattens to a flat array of fraction strings. + let json: Value = serde_json::from_slice(&bytes).unwrap(); + let kt = json["kt"].as_array().expect("kt flat array"); + assert_eq!(kt[0].as_str().unwrap(), "1/2"); + assert_eq!(kt[1].as_str().unwrap(), "1/2"); + let strict = icp_strict_eq_oracle(&bytes); + assert_eq!(*strict.threshold(), expected); + } + + #[test] + fn tholder_weighted_multi_clause_is_nested_array() { + let expected = Tholder::Weighted(vec![vec![(1, 2), (1, 2)], vec![(1, 1)]]); + let bytes = icp_with_kt(expected.clone(), 3); + // Multi-clause stays a nested array of arrays. + let json: Value = serde_json::from_slice(&bytes).unwrap(); + let kt = json["kt"].as_array().expect("kt nested array"); + assert!(kt[0].is_array(), "first clause is a nested array"); + assert!(kt[1].is_array(), "second clause is a nested array"); + let strict = icp_strict_eq_oracle(&bytes); + assert_eq!(*strict.threshold(), expected); + } + + // ------------------------------------------------------------------- + // Matrix D — `ParsedCount::Hex` through a non-trivial bt. + // (intive `bt` Number is covered by `intive_integer_bt_is_accepted`.) + // ------------------------------------------------------------------- + + #[test] + fn count_hex_bt_ten_renders_hex_and_roundtrips() { + let event = InceptionEvent::new( + make_prefixer().into(), + Seqner::new(0), + make_saider(), + vec![make_verfer()], + Tholder::Simple(1), + vec![make_diger()], + Tholder::Simple(1), + vec![], + 10, + vec![], + vec![], + ); + let bytes = serialize_inception(&event).unwrap().as_bytes().to_vec(); + // bt renders as hex: 10 -> "a". + let json: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(json["bt"].as_str().unwrap(), "a"); + let strict = icp_strict_eq_oracle(&bytes); + assert_eq!(strict.witness_threshold(), 10); + } + + // ------------------------------------------------------------------- + // Matrix E — `config_from_parsed` for both known codes. + // ------------------------------------------------------------------- + + #[test] + fn config_both_known_codes_are_pinned() { + let event = InceptionEvent::new( + make_prefixer().into(), + Seqner::new(0), + make_saider(), + vec![make_verfer()], + Tholder::Simple(1), + vec![make_diger()], + Tholder::Simple(1), + vec![], + 0, + vec![ConfigTrait::EstOnly, ConfigTrait::DoNotDelegate], + vec![], + ); + let bytes = serialize_inception(&event).unwrap().as_bytes().to_vec(); + let strict = icp_strict_eq_oracle(&bytes); + assert_eq!( + strict.config(), + [ConfigTrait::EstOnly, ConfigTrait::DoNotDelegate] + ); + } + + // ------------------------------------------------------------------- + // Matrix F — `deserialize_event` ilk dispatch, all 5 arms. + // ------------------------------------------------------------------- + + #[test] + fn dispatch_icp_arm_is_pinned() { + let bytes = serialize(&KeriEvent::Inception(probe_icp())) + .unwrap() + .as_bytes() + .to_vec(); + let event = deserialize_event(&bytes).unwrap(); + assert!(matches!(event, KeriEvent::Inception(_))); + let re = serialize(&event).unwrap(); + assert_eq!(re.as_bytes(), bytes, "dispatch re-serializes to original"); + } + + #[test] + fn dispatch_rot_arm_is_pinned() { + let bytes = serialize(&KeriEvent::Rotation(probe_rot())) + .unwrap() + .as_bytes() + .to_vec(); + let event = deserialize_event(&bytes).unwrap(); + assert!(matches!(event, KeriEvent::Rotation(_))); + let re = serialize(&event).unwrap(); + assert_eq!(re.as_bytes(), bytes, "dispatch re-serializes to original"); + } + + #[test] + fn dispatch_ixn_arm_is_pinned() { + let ixn = InteractionEvent::new( + make_prefixer().into(), + Seqner::new(1), + make_saider(), + make_saider(), + vec![], + ); + let bytes = serialize(&KeriEvent::Interaction(ixn)) + .unwrap() + .as_bytes() + .to_vec(); + let event = deserialize_event(&bytes).unwrap(); + assert!(matches!(event, KeriEvent::Interaction(_))); + let re = serialize(&event).unwrap(); + assert_eq!(re.as_bytes(), bytes, "dispatch re-serializes to original"); + } + + #[test] + fn dispatch_dip_arm_is_pinned() { + let dip = DelegatedInceptionEvent::new(probe_icp(), make_prefixer().into()); + let bytes = serialize(&KeriEvent::DelegatedInception(dip)) + .unwrap() + .as_bytes() + .to_vec(); + let event = deserialize_event(&bytes).unwrap(); + assert!(matches!(event, KeriEvent::DelegatedInception(_))); + let re = serialize(&event).unwrap(); + assert_eq!(re.as_bytes(), bytes, "dispatch re-serializes to original"); + } + + #[test] + fn dispatch_drt_arm_is_pinned() { + let drt = DelegatedRotationEvent::new(probe_rot()); + let bytes = serialize(&KeriEvent::DelegatedRotation(drt)) + .unwrap() + .as_bytes() + .to_vec(); + let event = deserialize_event(&bytes).unwrap(); + assert!(matches!(event, KeriEvent::DelegatedRotation(_))); + let re = serialize(&event).unwrap(); + assert_eq!(re.as_bytes(), bytes, "dispatch re-serializes to original"); + } + + // ------------------------------------------------------------------- + // Matrix G — reachability of each read-path error variant. + // + // Invariant of the #142 rewrite: the STRICT read path never returns + // `MissingField` — in the fixed canonical grammar a missing/absent + // field is a `NonCanonical` (the grammar expected a literal at that + // byte). `MissingField` is now oracle-only. `InvalidEventLayout` and + // `VersionStringOverflow` are internal / write-path signals, not + // reachable from untrusted read input, so they are NOT probed here. + // ------------------------------------------------------------------- + + /// `NonCanonical`: a reordered field name (same length keeps the size + /// field consistent) through a public `deserialize_*` entry point. + #[test] + fn error_non_canonical_from_reordered_field() { + let mut bytes = serialize_interaction(&InteractionEvent::new( + make_prefixer().into(), + Seqner::new(3), + make_saider(), + make_saider(), + vec![], + )) + .unwrap() + .as_bytes() + .to_vec(); + // Swap the `"s"` and `"p"` key names (equal length). + let s_pos = bytes.windows(5).position(|w| w == b",\"s\":").unwrap(); + let p_pos = bytes.windows(5).position(|w| w == b",\"p\":").unwrap(); + bytes[s_pos + 2] = b'p'; + bytes[p_pos + 2] = b's'; + assert!(matches!( + deserialize_interaction(&bytes), + Err(SerderError::NonCanonical { .. }) + )); + } + + /// The strict read path returns `NonCanonical`, NOT `MissingField`, + /// when a field is deleted: the grammar expected a literal at that + /// byte offset. This is the distinguishing property of the rewrite. + #[test] + fn field_deletion_is_non_canonical_never_missing_field() { + let bytes = serialize_interaction(&InteractionEvent::new( + make_prefixer().into(), + Seqner::new(3), + make_saider(), + make_saider(), + vec![], + )) + .unwrap() + .as_bytes() + .to_vec(); + // Delete the `,"p":"..."` field entirely (find `,"p":"` .. next `"`). + let p_key = bytes.windows(6).position(|w| w == b",\"p\":\"").unwrap(); + let val_start = p_key + 6; + let val_end = + val_start + bytes[val_start..].iter().position(|b| *b == b'"').unwrap() + 1; + let mut mutated = Vec::new(); + mutated.extend_from_slice(&bytes[..p_key]); + mutated.extend_from_slice(&bytes[val_end..]); + // Fix the version-string size field so the length check passes and + // the grammar itself is what rejects the missing field — otherwise + // `InvalidVersionString` (the length lie) would fire first. + let hex = format!("{:06x}", mutated.len()); + mutated[16..22].copy_from_slice(hex.as_bytes()); + let Err(err) = deserialize_interaction(&mutated) else { + unreachable!("field deletion must not deserialize") + }; + assert!( + matches!(err, SerderError::NonCanonical { .. }), + "strict deletion must be NonCanonical, got {err:?}" + ); + assert!( + !matches!(err, SerderError::MissingField(_)), + "strict read path must never return MissingField" + ); + } + + /// `InvalidVersionString`: a non-JSON serialization kind in the + /// version string. `deserialize_*_rejects_length_mismatched_raw` + /// already pins the length-mismatch route; this pins the wrong-kind + /// route through the strict path. + #[test] + fn error_invalid_version_string_wrong_kind() { + let mut mutated = serialize_interaction(&InteractionEvent::new( + make_prefixer().into(), + Seqner::new(1), + make_saider(), + make_saider(), + vec![], + )) + .unwrap() + .as_bytes() + .to_vec(); + // The version string is `KERI10JSON......_`; overwrite `JSON` + // (bytes 6..10) with `CBOR` — a different, valid serialization + // kind. Length is unchanged, so the size check still passes and + // the kind check is what fires. + mutated[6..10].copy_from_slice(b"CBOR"); + assert!( + matches!( + deserialize_interaction(&mutated), + Err(SerderError::InvalidVersionString(_)) + ), + "wrong version-string kind must be InvalidVersionString" + ); + } + + /// `SaidMismatch`: `tampered_said_fails_verification` already pins + /// this for icp via the strict path. Re-assert here for ixn to keep + /// the map complete (tamper a byte OUTSIDE the SAID span — the `s` + /// value — so the SAID no longer matches). + #[test] + fn error_said_mismatch_on_tampered_field() { + let mut mutated = serialize_interaction(&InteractionEvent::new( + make_prefixer().into(), + Seqner::new(1), + make_saider(), + make_saider(), + vec![], + )) + .unwrap() + .as_bytes() + .to_vec(); + // Replace sn value "1" with "2": same length, SAID span untouched. + let pos = mutated + .windows(8) + .position(|w| w == b",\"s\":\"1\"") + .unwrap(); + mutated[pos + 6] = b'2'; + assert!(matches!( + deserialize_interaction(&mutated), + Err(SerderError::SaidMismatch { .. }) + )); + } + + /// `UnknownIlk` at the PUBLIC `deserialize_event` layer: an unknown + /// (but correctly-lengthed) ilk code. `canonical.rs::unknown_ilk_is_typed` + /// pins the parse layer; this pins the public dispatch layer. + #[test] + fn error_unknown_ilk_at_public_dispatch() { + let mut bytes = serialize(&KeriEvent::Interaction(InteractionEvent::new( + make_prefixer().into(), + Seqner::new(1), + make_saider(), + make_saider(), + vec![], + ))) + .unwrap() + .as_bytes() + .to_vec(); + let pos = bytes.windows(5).position(|w| w == b"\"ixn\"").unwrap(); + bytes[pos + 1..pos + 4].copy_from_slice(b"xxx"); + assert!(matches!( + deserialize_event(&bytes), + Err(SerderError::UnknownIlk(ref s)) if s == "xxx" + )); + } + + /// `InvalidPrimitive`: a structurally-scannable but invalid field + /// value — a non-hex `s` (sequence number). The scanner accepts it as + /// a canonical string; `parse_sn` rejects it. Re-SAID first so the + /// mutation reaches the build layer (SAID verification passes over the + /// literal bytes). + #[test] + fn error_invalid_primitive_bad_hex_sn() { + let mut raw = serialize_inception(&probe_icp()) + .unwrap() + .as_bytes() + .to_vec(); + let pos = raw.windows(8).position(|w| w == b",\"s\":\"0\"").unwrap(); + // "0" -> "z": same length; not a hex digit. + raw[pos + 6] = b'z'; + let canonical = super::resaid(raw); + assert!(matches!( + deserialize_inception(&canonical), + Err(SerderError::InvalidPrimitive { field: "s", .. }) + )); + } + + /// `UnparseablePrimitive`: a malformed qb64 code in a field. The + /// unit test `unparseable_qb64_field_surfaces_as_parsing_domain_error` + /// already pins this directly on `parse_qb64_diger`; here we drive it + /// through the public read path by corrupting a key's leading code + /// character to an unparseable code, then re-SAID. + #[test] + fn error_unparseable_primitive_bad_qb64_key() { + let mut raw = serialize_inception(&probe_icp()) + .unwrap() + .as_bytes() + .to_vec(); + // Corrupt the first key's leading code char: the `k` array is + // `"k":["D..."]`; overwrite the `D` with `-` (a count-code lead, + // not a Matter primitive code) to force a parse-domain failure. + let k_pos = raw.windows(6).position(|w| w == b"\"k\":[\"").unwrap(); + let code_pos = k_pos + 6; + raw[code_pos] = b'-'; + let canonical = super::resaid(raw); + let Err(err) = deserialize_inception(&canonical) else { + unreachable!("corrupt key code must not deserialize") + }; + assert!( + matches!(err, SerderError::UnparseablePrimitive { field: "k", .. }), + "corrupt key code must be UnparseablePrimitive, got {err:?}" + ); + } + } } From dedb2999ac305de4af8b57b8099b7a08e254c6f1 Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sat, 11 Jul 2026 01:54:38 +0200 Subject: [PATCH 21/21] docs(serder): cross-reference variant-matrix tests with the suites they extend (#142) Co-Authored-By: Claude Fable 5 --- cesr/src/serder/deserialize.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cesr/src/serder/deserialize.rs b/cesr/src/serder/deserialize.rs index 8819f30..3633bff 100644 --- a/cesr/src/serder/deserialize.rs +++ b/cesr/src/serder/deserialize.rs @@ -1767,6 +1767,7 @@ mod tests { // Matrix E — `config_from_parsed` for both known codes. // ------------------------------------------------------------------- + /// Extends the pre-existing `roundtrip_config_traits` with oracle equivalence. #[test] fn config_both_known_codes_are_pinned() { let event = InceptionEvent::new( @@ -1794,6 +1795,7 @@ mod tests { // Matrix F — `deserialize_event` ilk dispatch, all 5 arms. // ------------------------------------------------------------------- + /// Extends `deserialize_event_dispatches_icp` with byte-reproduction of the original. #[test] fn dispatch_icp_arm_is_pinned() { let bytes = serialize(&KeriEvent::Inception(probe_icp())) @@ -1806,6 +1808,7 @@ mod tests { assert_eq!(re.as_bytes(), bytes, "dispatch re-serializes to original"); } + /// Extends `deserialize_event_dispatches_rot` with byte-reproduction of the original. #[test] fn dispatch_rot_arm_is_pinned() { let bytes = serialize(&KeriEvent::Rotation(probe_rot())) @@ -1818,6 +1821,7 @@ mod tests { assert_eq!(re.as_bytes(), bytes, "dispatch re-serializes to original"); } + /// Extends `deserialize_event_dispatches_ixn` with byte-reproduction of the original. #[test] fn dispatch_ixn_arm_is_pinned() { let ixn = InteractionEvent::new(