diff --git a/cesr/Cargo.toml b/cesr/Cargo.toml index 2dfedcc..e0e3f6a 100644 --- a/cesr/Cargo.toml +++ b/cesr/Cargo.toml @@ -130,6 +130,11 @@ name = "stream" harness = false required-features = ["stream"] +[[bench]] +name = "serder" +harness = false +required-features = ["serder"] + [[example]] name = "concurrent_parse" required-features = ["stream"] diff --git a/cesr/benches/serder.rs b/cesr/benches/serder.rs new file mode 100644 index 0000000..4c14989 --- /dev/null +++ b/cesr/benches/serder.rs @@ -0,0 +1,108 @@ +//! Event-serialization benchmarks for the #79 backend seam. +//! +//! Measures the same production entry point (`serialize_with`) through both +//! backends on identical events, so `CodSpeed` tracks the direct backend's +//! win over the `serde_json` reference — and any regression in either. +//! +//! Fixtures are deterministic (fixed raw bytes) for stable `CodSpeed` input. + +// The lints below fire only inside `codspeed-criterion-compat`'s +// `criterion_group!`/`criterion_main!` macro expansion — third-party macro code +// we cannot annotate per-item. Benches are host-only tooling, not shipped. +#![allow( + missing_docs, + clippy::disallowed_methods, + clippy::significant_drop_tightening, + reason = "fire only inside codspeed-criterion-compat macro expansion; not our code" +)] + +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 core::hint::black_box; +use criterion::{Criterion, criterion_group, criterion_main}; + +fn prefixer(byte: u8) -> Prefixer<'static> { + let built = MatterBuilder::new() + .with_code(VerKeyCode::Ed25519) + .with_raw(vec![byte; 32]); + if let Ok(b) = built + && let Ok(m) = b.build() + { + return m; + } + unreachable!("fixed 32-byte raw always builds an Ed25519 prefixer") +} + +fn saider(byte: u8) -> Saider<'static> { + let built = MatterBuilder::new() + .with_code(DigestCode::Blake3_256) + .with_raw(vec![byte; 32]); + if let Ok(b) = built + && let Ok(m) = b.build() + { + return m; + } + unreachable!("fixed 32-byte raw always builds a Blake3 saider") +} + +/// A representative inception: two keys, two next-key digests, one witness, +/// one config trait, two anchors. +fn fixture_icp() -> InceptionEvent { + InceptionEvent::new( + Identifier::Basic(prefixer(0)), + Seqner::new(0), + saider(1), + vec![prefixer(2), prefixer(3)], + Tholder::Simple(2), + vec![saider(4), saider(5)], + Tholder::Simple(2), + vec![prefixer(6)], + 1, + vec![ConfigTrait::EstOnly], + vec![ + Seal::Digest { d: saider(7) }, + Seal::Source { + s: Seqner::new(3), + d: saider(8), + }, + ], + ) +} + +/// An anchor-heavy interaction: 16 digest seals (the value-array hot loop). +fn fixture_ixn() -> InteractionEvent { + let anchors = (0..16_u8).map(|i| Seal::Digest { d: saider(i) }).collect(); + InteractionEvent::new( + Identifier::Basic(prefixer(0)), + Seqner::new(1), + saider(1), + saider(2), + anchors, + ) +} + +fn bench_serialize(c: &mut Criterion) { + let icp = fixture_icp(); + let ixn = fixture_ixn(); + + let mut group = c.benchmark_group("serder_serialize"); + group.bench_function("icp_serde_json", |b| { + b.iter(|| serialize_with(&SerdeJson, black_box(EventRef::Inception(&icp)))); + }); + group.bench_function("icp_direct", |b| { + b.iter(|| serialize_with(&DirectJson, black_box(EventRef::Inception(&icp)))); + }); + group.bench_function("ixn16_serde_json", |b| { + b.iter(|| serialize_with(&SerdeJson, black_box(EventRef::Interaction(&ixn)))); + }); + group.bench_function("ixn16_direct", |b| { + b.iter(|| serialize_with(&DirectJson, black_box(EventRef::Interaction(&ixn)))); + }); + group.finish(); +} + +criterion_group!(benches, bench_serialize); +criterion_main!(benches); diff --git a/cesr/src/serder/deserialize.rs b/cesr/src/serder/deserialize.rs index 38ef8ae..45218a2 100644 --- a/cesr/src/serder/deserialize.rs +++ b/cesr/src/serder/deserialize.rs @@ -566,6 +566,14 @@ fn parse_weight(s: &str) -> Result<(u64, u64), SerderError> { "invalid fraction denominator: {s}" )), })?; + if den == 0 { + return Err(SerderError::InvalidPrimitive { + field: "kt", + source: ValidationError::UnknownMatterCode(format!( + "zero denominator in weight: {s}" + )), + }); + } Ok((num, den)) } else { let val: u64 = s.parse().map_err(|_| SerderError::InvalidPrimitive { @@ -1307,6 +1315,21 @@ mod tests { assert_eq!((n, d), (1, 1)); } + #[test] + fn parse_weight_rejects_zero_denominator() { + // Bug probe: "0/0" and "1/0" previously parsed into (0,0)/(1,0), and + // a (0,0) weight made re-serialization divide by zero (panic on + // untrusted-derived data). + assert!(matches!( + parse_weight("0/0"), + Err(SerderError::InvalidPrimitive { field: "kt", .. }) + )); + assert!(matches!( + parse_weight("1/0"), + Err(SerderError::InvalidPrimitive { field: "kt", .. }) + )); + } + // ----------------------------------------------------------------------- // Version-string validation at every public entry point // ----------------------------------------------------------------------- diff --git a/cesr/src/serder/error.rs b/cesr/src/serder/error.rs index aea868c..036d87a 100644 --- a/cesr/src/serder/error.rs +++ b/cesr/src/serder/error.rs @@ -67,6 +67,12 @@ 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. + #[error("invalid event layout: {0}")] + InvalidEventLayout(&'static str), + /// Digest computation failed. #[error("digest error: {0}")] DigestError(String), diff --git a/cesr/src/serder/mod.rs b/cesr/src/serder/mod.rs index d595565..44581b6 100644 --- a/cesr/src/serder/mod.rs +++ b/cesr/src/serder/mod.rs @@ -43,7 +43,8 @@ pub use deserialize::{ }; pub use error::SerderError; pub use serialize::{ - SerializedEvent, serialize, serialize_delegated_inception, serialize_delegated_rotation, - serialize_inception, serialize_interaction, serialize_rotation, + DirectJson, EventLayout, EventRef, EventSerializer, SerdeJson, SerializedEvent, serialize, + serialize_delegated_inception, serialize_delegated_rotation, serialize_inception, + serialize_interaction, serialize_rotation, serialize_with, }; pub use traits::{KeriDeserialize, KeriSerialize}; diff --git a/cesr/src/serder/serialize.rs b/cesr/src/serder/serialize.rs index 7ee05e2..ba7e49a 100644 --- a/cesr/src/serder/serialize.rs +++ b/cesr/src/serder/serialize.rs @@ -12,6 +12,8 @@ use alloc::{borrow::ToOwned, format, string::String, vec, vec::Vec}; /// Delegated inception event serializer. pub mod dip; +/// Direct serialization backend (hand-rolled canonical JSON writer). +pub mod direct; /// Delegated rotation event serializer. pub mod drt; /// Inception event serializer. @@ -21,16 +23,23 @@ pub mod ixn; /// Rotation event serializer. pub mod rot; -use crate::core::matter::code::CesrCode; +use crate::core::matter::code::{CesrCode, DigestCode}; use crate::core::matter::matter::Matter; use crate::core::primitives::{Saider, Tholder}; -use crate::keri::{Identifier, Ilk, KeriEvent, Seal}; +use crate::keri::{ + DelegatedInceptionEvent, DelegatedRotationEvent, Identifier, Ilk, InceptionEvent, + InteractionEvent, KeriEvent, RotationEvent, Seal, +}; +use core::ops::Range; use serde_json::{Map, Value}; use crate::serder::error::SerderError; use crate::serder::primitives::{sn_to_hex, to_qb64_string}; +use crate::serder::said::{compute_digest, said_placeholder}; +use crate::serder::version::VERSION_SIZE_MAX; pub use dip::serialize_delegated_inception; +pub use direct::DirectJson; pub use drt::serialize_delegated_rotation; pub use icp::serialize_inception; pub use ixn::serialize_interaction; @@ -54,6 +63,282 @@ pub fn serialize(event: &KeriEvent) -> Result { } } +// --------------------------------------------------------------------------- +// Serialization backend seam +// --------------------------------------------------------------------------- + +/// Borrowed view over any KERI event, used to hand an event to a +/// serialization backend without cloning it into a [`KeriEvent`]. +#[derive(Clone, Copy)] +pub enum EventRef<'e> { + /// Inception (`icp`). + Inception(&'e InceptionEvent), + /// Rotation (`rot`). + Rotation(&'e RotationEvent), + /// Interaction (`ixn`). + Interaction(&'e InteractionEvent), + /// Delegated inception (`dip`). + DelegatedInception(&'e DelegatedInceptionEvent), + /// Delegated rotation (`drt`). + DelegatedRotation(&'e DelegatedRotationEvent), +} + +impl EventRef<'_> { + /// The event type (ilk) of the referenced event. + #[must_use] + pub const fn ilk(self) -> Ilk { + match self { + Self::Inception(_) => Ilk::Icp, + Self::Rotation(_) => Ilk::Rot, + Self::Interaction(_) => Ilk::Ixn, + Self::DelegatedInception(_) => Ilk::Dip, + Self::DelegatedRotation(_) => Ilk::Drt, + } + } + + /// Whether the event's identifier prefix is set to the computed SAID + /// (the double-SAID property of inception and delegated inception). + #[must_use] + pub const fn is_double_said(self) -> bool { + matches!(self, Self::Inception(_) | Self::DelegatedInception(_)) + } +} + +impl<'e> From<&'e KeriEvent> for EventRef<'e> { + fn from(event: &'e KeriEvent) -> Self { + match event { + KeriEvent::Inception(e) => Self::Inception(e), + KeriEvent::Rotation(e) => Self::Rotation(e), + KeriEvent::Interaction(e) => Self::Interaction(e), + KeriEvent::DelegatedInception(e) => Self::DelegatedInception(e), + KeriEvent::DelegatedRotation(e) => Self::DelegatedRotation(e), + } + } +} + +/// Byte ranges of the backpatchable slots inside a rendered event body. +/// +/// Ranges are absolute indices into the buffer as it stands when +/// [`EventSerializer::render`] returns. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EventLayout { + /// The six-hex-digit size field inside the version string. + pub size_slot: Range, + /// The `d` field's qb64 SAID value (placeholder until spliced). + pub said_slot: Range, + /// The `i` field's qb64 value for double-SAID events (`icp`/`dip`). + pub prefix_slot: Option>, +} + +/// A pluggable serialization backend: renders one event's canonical JSON +/// body into a caller-provided buffer and reports where the backpatchable +/// slots landed. +/// +/// The rendered body must carry a zero-size version string +/// (`KERI10JSON000000_`) and `said_placeholder` in every SAID slot. The +/// shared orchestration in [`serialize_with`] backpatches the measured size, +/// computes the SAID over the size-corrected bytes, and splices it into the +/// reported slots. Backends only control *how* the bytes are produced — +/// every backend must render byte-identical output for the same event. +pub trait EventSerializer { + /// Render `event` into `buf` (appending) and report the slot layout. + /// + /// # Errors + /// + /// Returns [`SerderError`] if the event cannot be rendered. + fn render( + &self, + event: EventRef<'_>, + said_placeholder: &str, + buf: &mut Vec, + ) -> Result; +} + +/// The reference backend: renders through `serde_json` exactly as the +/// pre-seam serializers did. +#[derive(Debug, Clone, Copy, Default)] +pub struct SerdeJson; + +impl EventSerializer for SerdeJson { + fn render( + &self, + event: EventRef<'_>, + said_placeholder: &str, + buf: &mut Vec, + ) -> Result { + let json = match event { + EventRef::Inception(e) => icp::render_json(e, said_placeholder)?, + EventRef::Rotation(e) => rot::render_json(e, said_placeholder)?, + EventRef::Interaction(e) => ixn::render_json(e, said_placeholder)?, + EventRef::DelegatedInception(e) => dip::render_json(e, said_placeholder)?, + EventRef::DelegatedRotation(e) => drt::render_json(e, said_placeholder)?, + }; + extend_with_layout(buf, &json, said_placeholder, event.is_double_said()) + } +} + +/// Serialize an event through an explicit backend. +/// +/// Shared orchestration for every backend: render once with a placeholder +/// SAID and zero-size version string, backpatch the measured size in place, +/// compute the SAID over the size-corrected bytes, and splice it into the +/// reported slot(s). This replaces the historical three-render pipeline — +/// both slots are fixed-width, so one render suffices. +/// +/// # Errors +/// +/// Returns [`SerderError`] if rendering fails, the event exceeds the +/// version string's size capacity, or the backend reports an inconsistent +/// layout. +pub fn serialize_with( + backend: &B, + event: EventRef<'_>, +) -> Result { + let digest_code = DigestCode::Blake3_256; + let placeholder = said_placeholder(digest_code)?; + + let mut buf = Vec::new(); + let layout = backend.render(event, &placeholder, &mut buf)?; + + let size = buf.len(); + let size_u32 = u32::try_from(size) + .ok() + .filter(|s| *s <= VERSION_SIZE_MAX) + .ok_or(SerderError::VersionStringOverflow { + field: "size", + max: VERSION_SIZE_MAX, + })?; + patch_slot( + &mut buf, + &layout.size_slot, + format!("{size_u32:06x}").as_bytes(), + )?; + + let said = compute_digest(&buf, digest_code)?; + let said_qb64 = to_qb64_string(&said); + patch_slot(&mut buf, &layout.said_slot, said_qb64.as_bytes())?; + + let prefix = layout + .prefix_slot + .as_ref() + .map(|slot| { + patch_slot(&mut buf, slot, said_qb64.as_bytes())?; + Ok::<_, SerderError>(said.clone()) + }) + .transpose()?; + + Ok(SerializedEvent { + raw: buf, + said, + prefix, + ilk: event.ilk(), + size, + event: (), + }) +} + +/// The zero-size v1 JSON version string every render must start from. +const ZERO_SIZE_VSTRING: &[u8] = b"KERI10JSON000000_"; +/// Length of the leading `{"v":"` that precedes the version string. +const VSTRING_OFFSET: usize = 6; +/// Offset of the six size hex digits inside the version string. +const SIZE_OFFSET_IN_VSTRING: usize = 10; +/// Width of the size field in hex digits. +const SIZE_WIDTH: usize = 6; + +/// Append `json` to `buf` and locate the version-size and SAID slots, +/// validating the render's framing along the way. +fn extend_with_layout( + buf: &mut Vec, + json: &str, + placeholder: &str, + double_said: bool, +) -> Result { + let base = buf.len(); + let bytes = json.as_bytes(); + + let vs_end = VSTRING_OFFSET.checked_add(ZERO_SIZE_VSTRING.len()).ok_or( + SerderError::InvalidEventLayout("version-string bounds overflow"), + )?; + if bytes.get(..VSTRING_OFFSET) != Some(br#"{"v":""#.as_slice()) + || bytes.get(VSTRING_OFFSET..vs_end) != Some(ZERO_SIZE_VSTRING) + { + return Err(SerderError::InvalidEventLayout( + "rendered JSON does not begin with a zero-size v1 version string", + )); + } + + let said_rel = find_subslice(bytes, placeholder.as_bytes(), 0).ok_or( + SerderError::InvalidEventLayout("SAID placeholder not found"), + )?; + let said_rel_end = said_rel + .checked_add(placeholder.len()) + .ok_or(SerderError::InvalidEventLayout("SAID slot bounds overflow"))?; + + let prefix_slot = if double_said { + let rel = find_subslice(bytes, placeholder.as_bytes(), said_rel_end).ok_or( + SerderError::InvalidEventLayout("second SAID placeholder not found"), + )?; + let rel_end = rel + .checked_add(placeholder.len()) + .ok_or(SerderError::InvalidEventLayout( + "prefix slot bounds overflow", + ))?; + Some(abs_range(base, rel..rel_end)?) + } else { + None + }; + + let size_start = VSTRING_OFFSET + .checked_add(SIZE_OFFSET_IN_VSTRING) + .ok_or(SerderError::InvalidEventLayout("size slot bounds overflow"))?; + let size_end = size_start + .checked_add(SIZE_WIDTH) + .ok_or(SerderError::InvalidEventLayout("size slot bounds overflow"))?; + + let layout = EventLayout { + size_slot: abs_range(base, size_start..size_end)?, + said_slot: abs_range(base, said_rel..said_rel_end)?, + prefix_slot, + }; + buf.extend_from_slice(bytes); + Ok(layout) +} + +/// Translate a render-relative range into a buffer-absolute range. +fn abs_range(base: usize, rel: Range) -> Result, SerderError> { + let start = base + .checked_add(rel.start) + .ok_or(SerderError::InvalidEventLayout("slot offset overflow"))?; + let end = base + .checked_add(rel.end) + .ok_or(SerderError::InvalidEventLayout("slot offset overflow"))?; + Ok(start..end) +} + +/// First occurrence of `needle` in `haystack` at or after `from`. +fn find_subslice(haystack: &[u8], needle: &[u8], from: usize) -> Option { + haystack + .get(from..)? + .windows(needle.len()) + .position(|w| w == needle) + .and_then(|rel| from.checked_add(rel)) +} + +/// Overwrite a fixed-width slot in place, verifying bounds and width. +fn patch_slot(buf: &mut [u8], slot: &Range, replacement: &[u8]) -> Result<(), SerderError> { + let dst = buf + .get_mut(slot.clone()) + .ok_or(SerderError::InvalidEventLayout("slot out of bounds"))?; + if dst.len() != replacement.len() { + return Err(SerderError::InvalidEventLayout( + "slot width does not match replacement", + )); + } + dst.copy_from_slice(replacement); + Ok(()) +} + /// A fully serialized KERI event with computed SAID. /// /// The type parameter `E` carries the deserialized event when constructed via @@ -174,13 +459,7 @@ pub(crate) fn tholder_to_json(tholder: &Tholder) -> Value { .map(|clause| { let inner: Vec = clause .iter() - .map(|(num, den)| { - if *num == 0 || (*den != 0 && *num == *den) { - Value::String(format!("{}", *num / *den)) - } else { - Value::String(format!("{num}/{den}")) - } - }) + .map(|(num, den)| Value::String(weight_to_string(*num, *den))) .collect(); Value::Array(inner) }) @@ -194,6 +473,19 @@ pub(crate) fn tholder_to_json(tholder: &Tholder) -> Value { } } +/// Render one weight fraction the way keripy's `Tholder.sith` does: whole +/// values collapse to their integer string (`0`, `1`), everything else stays +/// `num/den`. A zero denominator is malformed (rejected by both +/// `Tholder::check_well_formed` and the deserializer) but must render as a +/// plain fraction rather than dividing by zero. +pub(crate) fn weight_to_string(num: u64, den: u64) -> String { + if den != 0 && (num == 0 || num == den) { + format!("{}", num / den) + } else { + format!("{num}/{den}") + } +} + /// Convert a slice of [`Matter`] primitives to a JSON array of qb64 strings. /// /// qb64 encoding of CESR primitives is infallible, so this never fails. @@ -209,6 +501,16 @@ pub(crate) fn matters_to_json_array(matters: &[Matter<'_, C>]) -> V mod tests { use super::*; use crate::core::matter::builder::MatterBuilder; + + #[test] + fn tholder_zero_denominator_renders_without_panicking() { + // Bug probe: a (0, 0) weight previously hit `0 / 0` inside + // tholder_to_json and panicked. Malformed weights must render as a + // plain fraction; rejection happens at parse/validation boundaries. + let tholder = Tholder::Weighted(vec![vec![(0, 0), (1, 0)]]); + let rendered = tholder_to_json(&tholder); + assert_eq!(rendered, serde_json::json!(["0/0", "1/0"])); + } use crate::core::matter::code::{DigestCode, VerKeyCode}; use crate::core::primitives::{Diger, Prefixer, Saider, Seqner, Tholder, Verfer}; use crate::keri::{ @@ -407,4 +709,303 @@ mod tests { assert_eq!(arr[1].as_str().expect("1/2"), "1/2"); assert_eq!(arr[2].as_str().expect("1"), "1"); } + + // ----------------------------------------------------------------------- + // weight_to_string — exact mapping table (shared by both backends) + // ----------------------------------------------------------------------- + + #[test] + fn weight_to_string_exact_mapping() { + // Whole values collapse to their integer string; everything else — + // including malformed zero denominators and unreduced fractions — + // stays num/den verbatim (keripy does not reduce). + assert_eq!(weight_to_string(0, 1), "0"); + assert_eq!(weight_to_string(1, 1), "1"); + assert_eq!(weight_to_string(2, 2), "1"); + assert_eq!(weight_to_string(u64::MAX, u64::MAX), "1"); + assert_eq!(weight_to_string(1, 2), "1/2"); + assert_eq!(weight_to_string(2, 4), "2/4"); + assert_eq!(weight_to_string(3, 2), "3/2"); + assert_eq!(weight_to_string(0, 0), "0/0"); + assert_eq!(weight_to_string(1, 0), "1/0"); + assert_eq!(weight_to_string(u64::MAX, 1), "18446744073709551615/1"); + } + + // ----------------------------------------------------------------------- + // EventRef — ilk / double-SAID / From<&KeriEvent> mapping + // ----------------------------------------------------------------------- + + fn probe_icp_event() -> InceptionEvent { + 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![], + vec![], + ) + } + + fn probe_rot_event() -> RotationEvent { + RotationEvent::new( + make_prefixer().into(), + Seqner::new(1), + make_saider(), + make_saider(), + vec![make_verfer()], + Tholder::Simple(1), + vec![make_diger()], + Tholder::Simple(1), + vec![], + vec![], + 0, + vec![], + vec![], + ) + } + + fn probe_ixn_event() -> InteractionEvent { + InteractionEvent::new( + make_prefixer().into(), + Seqner::new(1), + make_saider(), + make_saider(), + vec![], + ) + } + + #[test] + fn event_ref_ilk_and_double_said_mapping() { + let icp = probe_icp_event(); + let rot = probe_rot_event(); + let ixn = probe_ixn_event(); + let dip = DelegatedInceptionEvent::new(probe_icp_event(), make_prefixer().into()); + let drt = DelegatedRotationEvent::new(probe_rot_event()); + + let cases: [(EventRef<'_>, Ilk, bool); 5] = [ + (EventRef::Inception(&icp), Ilk::Icp, true), + (EventRef::Rotation(&rot), Ilk::Rot, false), + (EventRef::Interaction(&ixn), Ilk::Ixn, false), + (EventRef::DelegatedInception(&dip), Ilk::Dip, true), + (EventRef::DelegatedRotation(&drt), Ilk::Drt, false), + ]; + for (event, ilk, double_said) in cases { + assert_eq!(event.ilk(), ilk); + assert_eq!(event.is_double_said(), double_said, "ilk {ilk:?}"); + } + } + + #[test] + fn event_ref_from_keri_event_preserves_variant() { + let events = [ + (KeriEvent::Inception(probe_icp_event()), Ilk::Icp), + (KeriEvent::Rotation(probe_rot_event()), Ilk::Rot), + (KeriEvent::Interaction(probe_ixn_event()), Ilk::Ixn), + ( + KeriEvent::DelegatedInception(DelegatedInceptionEvent::new( + probe_icp_event(), + make_prefixer().into(), + )), + Ilk::Dip, + ), + ( + KeriEvent::DelegatedRotation(DelegatedRotationEvent::new(probe_rot_event())), + Ilk::Drt, + ), + ]; + for (event, ilk) in &events { + assert_eq!(EventRef::from(event).ilk(), *ilk); + } + } + + // ----------------------------------------------------------------------- + // Hostile-backend boundary: EventSerializer is a PUBLIC trait, so + // serialize_with must survive any layout a buggy or malicious backend + // reports — typed InvalidEventLayout, never a panic or corrupt frame. + // ----------------------------------------------------------------------- + + struct HostileBackend { + rendered: &'static [u8], + layout: EventLayout, + } + + impl EventSerializer for HostileBackend { + fn render( + &self, + _event: EventRef<'_>, + _said_placeholder: &str, + buf: &mut Vec, + ) -> Result { + buf.extend_from_slice(self.rendered); + Ok(self.layout.clone()) + } + } + + fn expect_layout_error(backend: &HostileBackend) { + let ixn = probe_ixn_event(); + let result = serialize_with(backend, EventRef::Interaction(&ixn)); + assert!( + matches!(result, Err(SerderError::InvalidEventLayout(_))), + "a bogus layout must surface as InvalidEventLayout, never panic" + ); + } + + #[test] + fn hostile_backend_out_of_bounds_size_slot_is_rejected() { + expect_layout_error(&HostileBackend { + rendered: b"0123456789", + layout: EventLayout { + size_slot: 100..106, + said_slot: 0..2, + prefix_slot: None, + }, + }); + } + + #[test] + fn hostile_backend_wrong_width_size_slot_is_rejected() { + // Slot inside bounds but 2 bytes wide; the size patch is 6 bytes. + expect_layout_error(&HostileBackend { + rendered: b"0123456789", + layout: EventLayout { + size_slot: 0..2, + said_slot: 2..4, + prefix_slot: None, + }, + }); + } + + #[test] + fn hostile_backend_wrong_width_said_slot_is_rejected() { + // Valid 6-wide size slot, but the SAID slot cannot hold a 44-char qb64. + expect_layout_error(&HostileBackend { + rendered: b"0123456789", + layout: EventLayout { + size_slot: 0..6, + said_slot: 6..8, + prefix_slot: None, + }, + }); + } + + #[test] + fn hostile_backend_reversed_range_is_rejected() { + // Constructed as a struct literal: a hostile impl can produce a + // reversed Range at runtime even though the `6..0` expression form + // is a compile-time lint. + expect_layout_error(&HostileBackend { + rendered: b"0123456789", + layout: EventLayout { + size_slot: Range { start: 6, end: 0 }, + said_slot: 0..2, + prefix_slot: None, + }, + }); + } + + #[test] + fn hostile_backend_out_of_bounds_prefix_slot_is_rejected() { + // Big enough render for the size + SAID patches to land; the prefix + // slot lies past the end of the buffer. + const RENDERED: [u8; 64] = [b'x'; 64]; + expect_layout_error(&HostileBackend { + rendered: &RENDERED, + layout: EventLayout { + size_slot: 0..6, + said_slot: 6..50, + prefix_slot: Some(1000..1044), + }, + }); + } + + // ----------------------------------------------------------------------- + // extend_with_layout — framing validation and base-offset arithmetic + // ----------------------------------------------------------------------- + + fn placeholder() -> String { + said_placeholder(DigestCode::Blake3_256).expect("Blake3-256 has a fixed placeholder") + } + + #[test] + fn extend_with_layout_rejects_render_without_version_head() { + let ph = placeholder(); + let mut buf = Vec::new(); + let result = extend_with_layout(&mut buf, "{\"x\":1}", &ph, false); + assert!(matches!(result, Err(SerderError::InvalidEventLayout(_)))); + } + + #[test] + fn extend_with_layout_rejects_nonzero_size_version_head() { + let ph = placeholder(); + let mut buf = Vec::new(); + let json = format!("{{\"v\":\"KERI10JSON0000a1_\",\"d\":\"{ph}\"}}"); + let result = extend_with_layout(&mut buf, &json, &ph, false); + assert!( + matches!(result, Err(SerderError::InvalidEventLayout(_))), + "a render must start from a zero-size version string" + ); + } + + #[test] + fn extend_with_layout_rejects_missing_placeholder() { + let ph = placeholder(); + let mut buf = Vec::new(); + let json = "{\"v\":\"KERI10JSON000000_\",\"t\":\"ixn\"}"; + let result = extend_with_layout(&mut buf, json, &ph, false); + assert!(matches!(result, Err(SerderError::InvalidEventLayout(_)))); + } + + #[test] + fn extend_with_layout_rejects_missing_second_placeholder_for_double_said() { + let ph = placeholder(); + let mut buf = Vec::new(); + let json = format!("{{\"v\":\"KERI10JSON000000_\",\"d\":\"{ph}\"}}"); + let result = extend_with_layout(&mut buf, &json, &ph, true); + assert!( + matches!(result, Err(SerderError::InvalidEventLayout(_))), + "double-SAID events must report two placeholder slots" + ); + } + + #[test] + fn extend_with_layout_offsets_are_absolute_into_prefilled_buffer() { + let ph = placeholder(); + let mut buf = b"PREFILLED".to_vec(); + let base = buf.len(); + let json = format!("{{\"v\":\"KERI10JSON000000_\",\"d\":\"{ph}\",\"i\":\"{ph}\"}}"); + let layout = extend_with_layout(&mut buf, &json, &ph, true).unwrap(); + + assert_eq!(&buf[layout.size_slot.clone()], b"000000"); + assert_eq!(&buf[layout.said_slot.clone()], ph.as_bytes()); + let prefix_slot = layout.prefix_slot.expect("double-SAID reports two slots"); + assert_eq!(&buf[prefix_slot.clone()], ph.as_bytes()); + assert!( + layout.said_slot.start > base && prefix_slot.start > layout.said_slot.end, + "slots must be absolute (past the prefilled bytes) and in order" + ); + assert_eq!(&buf[..base], b"PREFILLED"); + } + + // ----------------------------------------------------------------------- + // SerdeJson::render appends — callers may reuse a non-empty buffer + // ----------------------------------------------------------------------- + + #[test] + fn serde_json_render_into_prefilled_buffer_reports_absolute_slots() { + let ph = placeholder(); + let ixn = probe_ixn_event(); + let mut buf = b"JUNK".to_vec(); + let layout = SerdeJson + .render(EventRef::Interaction(&ixn), &ph, &mut buf) + .unwrap(); + assert_eq!(&buf[..4], b"JUNK", "render must append, not overwrite"); + assert_eq!(&buf[layout.size_slot], b"000000"); + assert_eq!(&buf[layout.said_slot], ph.as_bytes()); + assert!(layout.prefix_slot.is_none(), "ixn is single-SAID"); + } } diff --git a/cesr/src/serder/serialize/dip.rs b/cesr/src/serder/serialize/dip.rs index 01d8305..00a032f 100644 --- a/cesr/src/serder/serialize/dip.rs +++ b/cesr/src/serder/serialize/dip.rs @@ -1,7 +1,6 @@ //! Delegated inception event (`dip`) serialization. -use crate::core::matter::code::DigestCode; -use crate::keri::{DelegatedInceptionEvent, Ilk}; +use crate::keri::DelegatedInceptionEvent; #[cfg(feature = "alloc")] #[allow( unused_imports, @@ -10,11 +9,13 @@ use crate::keri::{DelegatedInceptionEvent, Ilk}; use alloc::{borrow::ToOwned, string::String, string::ToString, vec, vec::Vec}; use serde_json::{Map, Value}; -use super::{SerializedEvent, matters_to_json_array, seal_to_json, tholder_to_json}; +use super::{ + EventRef, SerdeJson, SerializedEvent, matters_to_json_array, seal_to_json, serialize_with, + tholder_to_json, +}; use crate::serder::error::SerderError; -use crate::serder::primitives::{identifier_to_qb64_string, sn_to_hex, to_qb64_string}; -use crate::serder::said::{compute_digest, said_placeholder}; -use crate::serder::version::{VERSION_SIZE_MAX, VersionString}; +use crate::serder::primitives::{identifier_to_qb64_string, sn_to_hex}; +use crate::serder::version::VersionString; /// Serialize a [`DelegatedInceptionEvent`] to canonical JSON with a computed SAID. /// @@ -32,9 +33,15 @@ use crate::serder::version::{VERSION_SIZE_MAX, VersionString}; pub fn serialize_delegated_inception( event: &DelegatedInceptionEvent, ) -> Result { - let digest_code = DigestCode::Blake3_256; - let placeholder = said_placeholder(digest_code)?; + serialize_with(&SerdeJson, EventRef::DelegatedInception(event)) +} +/// Render the event body as canonical JSON with a zero-size version string +/// and `said_placeholder` in both SAID slots (`d` and `i`). +pub(crate) fn render_json( + event: &DelegatedInceptionEvent, + said_placeholder: &str, +) -> Result { let icp = event.inception(); let sn_hex = sn_to_hex(icp.sn().value()); let kt = tholder_to_json(icp.threshold()); @@ -71,35 +78,8 @@ pub fn serialize_delegated_inception( delegator: &delegator_qb64, }; - let phase1_vs = VersionString::keri_json_v1().to_str()?; - let phase1_json = build_dip_json(&phase1_vs, &placeholder, &fields)?; - let measured_len = u32::try_from(phase1_json.len()) - .ok() - .filter(|len| *len <= VERSION_SIZE_MAX) - .ok_or(SerderError::VersionStringOverflow { - field: "size", - max: VERSION_SIZE_MAX, - })?; - - let vs_with_size = VersionString::keri_json_v1() - .with_size(measured_len) - .to_str()?; - let phase2_json = build_dip_json(&vs_with_size, &placeholder, &fields)?; - - let said = compute_digest(phase2_json.as_bytes(), digest_code)?; - let said_qb64 = to_qb64_string(&said); - - let final_json = build_dip_json(&vs_with_size, &said_qb64, &fields)?; - - let size = final_json.len(); - Ok(SerializedEvent { - raw: final_json.into_bytes(), - said, - prefix: Some(compute_digest(phase2_json.as_bytes(), digest_code)?), - ilk: Ilk::Dip, - size, - event: (), - }) + let vs = VersionString::keri_json_v1().to_str()?; + build_dip_json(&vs, said_placeholder, &fields) } struct DipFields<'a> { @@ -144,6 +124,7 @@ mod tests { use crate::core::matter::builder::MatterBuilder; use crate::core::matter::code::{DigestCode, VerKeyCode}; use crate::core::primitives::{Diger, Prefixer, Saider, Seqner, Tholder, Verfer}; + use crate::keri::Ilk; use crate::keri::InceptionEvent; use alloc::borrow::Cow; diff --git a/cesr/src/serder/serialize/direct.rs b/cesr/src/serder/serialize/direct.rs new file mode 100644 index 0000000..7e66e6a --- /dev/null +++ b/cesr/src/serder/serialize/direct.rs @@ -0,0 +1,707 @@ +//! Direct serialization backend: a hand-rolled canonical JSON writer. +//! +//! Emits the five fixed KERI event grammars straight into the caller's +//! buffer — no `serde_json::Value` tree, no intermediate `String` per +//! render — recording the backpatchable slot offsets as it writes. Output +//! is byte-identical to the [`SerdeJson`](super::SerdeJson) reference +//! backend; the cross-backend property tests in this module are the gate. + +use crate::core::matter::code::CesrCode; +use crate::core::matter::matter::Matter; +use crate::core::primitives::Tholder; +#[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 super::{EventLayout, EventRef, EventSerializer, weight_to_string}; +use crate::keri::{ConfigTrait, InceptionEvent, InteractionEvent, RotationEvent, Seal}; +use crate::serder::error::SerderError; +use crate::serder::primitives::{identifier_to_qb64_string, sn_to_hex, to_qb64_string}; +use crate::serder::version::VersionString; + +/// The direct backend: writes canonical JSON straight into the caller's +/// buffer. +/// +/// Field names and framing are compile-time constants per ilk; values are +/// qb64/hex/ASCII strings written through a full RFC 8259 escaper (the +/// escaper is defense-in-depth — no current value class needs escaping). +#[derive(Debug, Clone, Copy, Default)] +pub struct DirectJson; + +impl EventSerializer for DirectJson { + fn render( + &self, + event: EventRef<'_>, + said_placeholder: &str, + buf: &mut Vec, + ) -> Result { + match event { + EventRef::Inception(e) => render_icp(buf, e, said_placeholder, "icp", None), + EventRef::Rotation(e) => render_rot(buf, e, said_placeholder, "rot"), + EventRef::Interaction(e) => render_ixn(buf, e, said_placeholder), + EventRef::DelegatedInception(e) => { + let delegator = identifier_to_qb64_string(e.delegator()); + render_icp( + buf, + e.inception(), + said_placeholder, + "dip", + Some(&delegator), + ) + } + EventRef::DelegatedRotation(e) => { + render_rot(buf, e.rotation(), said_placeholder, "drt") + } + } + } +} + +/// Write the shared `{"v":"","t":"","d":"` +/// head and return the size slot plus the `d` slot. +fn write_head( + buf: &mut Vec, + ilk: &str, + placeholder: &str, +) -> Result<(Range, Range), SerderError> { + let vs = VersionString::keri_json_v1().to_str()?; + buf.extend_from_slice(b"{\"v\":\""); + let vs_start = buf.len(); + buf.extend_from_slice(vs.as_bytes()); + let size_start = vs_start + .checked_add(10) + .ok_or(SerderError::InvalidEventLayout("size slot offset overflow"))?; + let size_end = size_start + .checked_add(6) + .ok_or(SerderError::InvalidEventLayout("size slot offset overflow"))?; + + buf.extend_from_slice(b"\",\"t\":"); + write_str(buf, ilk); + buf.extend_from_slice(b",\"d\":\""); + let d_start = buf.len(); + buf.extend_from_slice(placeholder.as_bytes()); + let d_end = buf.len(); + buf.push(b'"'); + Ok((size_start..size_end, d_start..d_end)) +} + +fn render_icp( + buf: &mut Vec, + e: &InceptionEvent, + placeholder: &str, + ilk: &str, + delegator: Option<&str>, +) -> Result { + let (size_slot, said_slot) = write_head(buf, ilk, placeholder)?; + + buf.extend_from_slice(b",\"i\":\""); + let i_start = buf.len(); + buf.extend_from_slice(placeholder.as_bytes()); + let prefix_slot = Some(i_start..buf.len()); + buf.push(b'"'); + + buf.extend_from_slice(b",\"s\":"); + write_str(buf, &sn_to_hex(e.sn().value())); + buf.extend_from_slice(b",\"kt\":"); + write_tholder(buf, e.threshold()); + buf.extend_from_slice(b",\"k\":"); + write_qb64_array(buf, e.keys()); + buf.extend_from_slice(b",\"nt\":"); + write_tholder(buf, e.next_threshold()); + buf.extend_from_slice(b",\"n\":"); + write_qb64_array(buf, e.next_keys()); + buf.extend_from_slice(b",\"bt\":"); + write_str(buf, &sn_to_hex(u128::from(e.witness_threshold()))); + buf.extend_from_slice(b",\"b\":"); + write_qb64_array(buf, e.witnesses()); + buf.extend_from_slice(b",\"c\":"); + write_config_array(buf, e.config()); + buf.extend_from_slice(b",\"a\":"); + write_seal_array(buf, e.anchors()); + if let Some(di) = delegator { + buf.extend_from_slice(b",\"di\":"); + write_str(buf, di); + } + buf.push(b'}'); + + Ok(EventLayout { + size_slot, + said_slot, + prefix_slot, + }) +} + +fn render_rot( + buf: &mut Vec, + e: &RotationEvent, + placeholder: &str, + ilk: &str, +) -> Result { + let (size_slot, said_slot) = write_head(buf, ilk, placeholder)?; + + buf.extend_from_slice(b",\"i\":"); + write_str(buf, &identifier_to_qb64_string(e.prefix())); + buf.extend_from_slice(b",\"s\":"); + write_str(buf, &sn_to_hex(e.sn().value())); + buf.extend_from_slice(b",\"p\":"); + write_str(buf, &to_qb64_string(e.prior_event_said())); + buf.extend_from_slice(b",\"kt\":"); + write_tholder(buf, e.threshold()); + buf.extend_from_slice(b",\"k\":"); + write_qb64_array(buf, e.keys()); + buf.extend_from_slice(b",\"nt\":"); + write_tholder(buf, e.next_threshold()); + buf.extend_from_slice(b",\"n\":"); + write_qb64_array(buf, e.next_keys()); + buf.extend_from_slice(b",\"bt\":"); + write_str(buf, &sn_to_hex(u128::from(e.witness_threshold()))); + buf.extend_from_slice(b",\"br\":"); + write_qb64_array(buf, e.witness_removals()); + buf.extend_from_slice(b",\"ba\":"); + write_qb64_array(buf, e.witness_additions()); + buf.extend_from_slice(b",\"a\":"); + write_seal_array(buf, e.anchors()); + buf.push(b'}'); + + Ok(EventLayout { + size_slot, + said_slot, + prefix_slot: None, + }) +} + +fn render_ixn( + buf: &mut Vec, + e: &InteractionEvent, + placeholder: &str, +) -> Result { + let (size_slot, said_slot) = write_head(buf, "ixn", placeholder)?; + + buf.extend_from_slice(b",\"i\":"); + write_str(buf, &identifier_to_qb64_string(e.prefix())); + buf.extend_from_slice(b",\"s\":"); + write_str(buf, &sn_to_hex(e.sn().value())); + buf.extend_from_slice(b",\"p\":"); + write_str(buf, &to_qb64_string(e.prior_event_said())); + buf.extend_from_slice(b",\"a\":"); + write_seal_array(buf, e.anchors()); + buf.push(b'}'); + + Ok(EventLayout { + size_slot, + said_slot, + prefix_slot: None, + }) +} + +const HEX: [u8; 16] = *b"0123456789abcdef"; + +/// Write `s` as a JSON string with RFC 8259 escaping, byte-identical to +/// `serde_json`'s escaper: `"`, `\`, and control characters below 0x20 are +/// escaped (short forms where they exist, `\u00xx` otherwise); everything +/// else — including multi-byte UTF-8 — passes through raw. +fn write_str(buf: &mut Vec, s: &str) { + buf.push(b'"'); + for &byte in s.as_bytes() { + match byte { + b'"' => buf.extend_from_slice(b"\\\""), + b'\\' => buf.extend_from_slice(b"\\\\"), + 0x08 => buf.extend_from_slice(b"\\b"), + 0x09 => buf.extend_from_slice(b"\\t"), + 0x0A => buf.extend_from_slice(b"\\n"), + 0x0C => buf.extend_from_slice(b"\\f"), + 0x0D => buf.extend_from_slice(b"\\r"), + b if b < 0x20 => { + buf.extend_from_slice(b"\\u00"); + buf.push(HEX[usize::from(b >> 4)]); + buf.push(HEX[usize::from(b & 0x0F)]); + } + b => buf.push(b), + } + } + buf.push(b'"'); +} + +/// Mirror of [`super::matters_to_json_array`]: a JSON array of qb64 strings. +fn write_qb64_array(buf: &mut Vec, matters: &[Matter<'_, C>]) { + buf.push(b'['); + for (idx, m) in matters.iter().enumerate() { + if idx > 0 { + buf.push(b','); + } + write_str(buf, &to_qb64_string(m)); + } + buf.push(b']'); +} + +/// Mirror of [`super::tholder_to_json`]: simple thresholds as hex strings, +/// single weighted clauses flattened, multiple clauses nested. +fn write_tholder(buf: &mut Vec, tholder: &Tholder) { + match tholder { + Tholder::Simple(n) => write_str(buf, &format!("{n:x}")), + Tholder::Weighted(clauses) => { + if let [single] = clauses.as_slice() { + write_weight_clause(buf, single); + } else { + buf.push(b'['); + for (idx, clause) in clauses.iter().enumerate() { + if idx > 0 { + buf.push(b','); + } + write_weight_clause(buf, clause); + } + buf.push(b']'); + } + } + } +} + +fn write_weight_clause(buf: &mut Vec, clause: &[(u64, u64)]) { + buf.push(b'['); + for (idx, (num, den)) in clause.iter().enumerate() { + if idx > 0 { + buf.push(b','); + } + write_str(buf, &weight_to_string(*num, *den)); + } + buf.push(b']'); +} + +fn write_config_array(buf: &mut Vec, config: &[ConfigTrait]) { + buf.push(b'['); + for (idx, c) in config.iter().enumerate() { + if idx > 0 { + buf.push(b','); + } + write_str(buf, c.code()); + } + buf.push(b']'); +} + +/// Mirror of [`super::seal_to_json`]: each seal variant's fixed field order. +fn write_seal(buf: &mut Vec, seal: &Seal) { + match seal { + Seal::Digest { d } => { + buf.extend_from_slice(b"{\"d\":"); + write_str(buf, &to_qb64_string(d)); + buf.push(b'}'); + } + Seal::Root { rd } => { + buf.extend_from_slice(b"{\"rd\":"); + write_str(buf, &to_qb64_string(rd)); + buf.push(b'}'); + } + Seal::Source { s, d } => { + buf.extend_from_slice(b"{\"s\":"); + write_str(buf, &sn_to_hex(s.value())); + buf.extend_from_slice(b",\"d\":"); + write_str(buf, &to_qb64_string(d)); + buf.push(b'}'); + } + Seal::Event { i, s, d } => { + buf.extend_from_slice(b"{\"i\":"); + write_str(buf, &to_qb64_string(i)); + buf.extend_from_slice(b",\"s\":"); + write_str(buf, &sn_to_hex(s.value())); + buf.extend_from_slice(b",\"d\":"); + write_str(buf, &to_qb64_string(d)); + buf.push(b'}'); + } + Seal::Last { i } => { + buf.extend_from_slice(b"{\"i\":"); + write_str(buf, &to_qb64_string(i)); + buf.push(b'}'); + } + } +} + +fn write_seal_array(buf: &mut Vec, seals: &[Seal]) { + buf.push(b'['); + for (idx, seal) in seals.iter().enumerate() { + if idx > 0 { + buf.push(b','); + } + write_seal(buf, seal); + } + buf.push(b']'); +} + +#[cfg(test)] +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::keri::{DelegatedInceptionEvent, DelegatedRotationEvent, Identifier}; + use crate::serder::deserialize::deserialize_inception; + use alloc::borrow::Cow; + 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(); + assert_eq!( + core::str::from_utf8(reference.as_bytes()).unwrap(), + core::str::from_utf8(direct.as_bytes()).unwrap(), + "direct backend must be byte-identical to the serde_json reference" + ); + assert_eq!( + to_qb64_string(reference.said()), + to_qb64_string(direct.said()) + ); + 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))] + + #[test] + fn icp_backends_byte_identical(spec in icp_strategy()) { + let event = build_icp(spec); + assert_backends_identical(EventRef::Inception(&event)); + } + + #[test] + fn rot_backends_byte_identical(spec in rot_strategy()) { + let event = build_rot(spec); + assert_backends_identical(EventRef::Rotation(&event)); + } + + #[test] + fn ixn_backends_byte_identical(spec in ixn_strategy()) { + let event = build_ixn(spec); + assert_backends_identical(EventRef::Interaction(&event)); + } + + #[test] + fn dip_backends_byte_identical( + spec in icp_strategy(), + delegator in any::(), + ) { + let dip = + DelegatedInceptionEvent::new(build_icp(spec), build_identifier(delegator)); + assert_backends_identical(EventRef::DelegatedInception(&dip)); + } + + #[test] + fn drt_backends_byte_identical(spec in rot_strategy()) { + let drt = DelegatedRotationEvent::new(build_rot(spec)); + assert_backends_identical(EventRef::DelegatedRotation(&drt)); + } + + #[test] + fn escaper_matches_serde_json_arbitrary_unicode(s in any::()) { + // any::() reaches control characters and unpaired-surrogate + // -adjacent code points that the ".*" regex strategy under-samples. + let mut buf = Vec::new(); + write_str(&mut buf, &s); + let expected = + serde_json::to_string(&serde_json::Value::String(s.clone())).unwrap(); + prop_assert_eq!(core::str::from_utf8(&buf).unwrap(), expected.as_str()); + } + + #[test] + fn escaper_matches_serde_json(s in ".*") { + let mut buf = Vec::new(); + write_str(&mut buf, &s); + let expected = + serde_json::to_string(&serde_json::Value::String(s.clone())).unwrap(); + prop_assert_eq!(core::str::from_utf8(&buf).unwrap(), expected.as_str()); + } + } + + #[test] + fn escaper_covers_every_escape_class() { + // One deterministic probe per escape class: quote, backslash, the + // five short escapes, \u00xx fallbacks (NUL, 0x1F), the unescaped + // DEL boundary (0x7F), and multi-byte UTF-8 passthrough. + let s = "q\" b\\ \u{8}\t\n\u{c}\r \u{0}\u{1f}\u{7f} héllo → 日本"; + let mut buf = Vec::new(); + write_str(&mut buf, s); + let expected = serde_json::to_string(&serde_json::Value::String(s.to_owned())).unwrap(); + assert_eq!(core::str::from_utf8(&buf).unwrap(), expected); + } + + #[test] + fn empty_weighted_thresholds_are_byte_identical_across_backends() { + // Boundary shapes the strategies can under-sample: an empty clause + // list and a single empty clause both render as "[]" on both + // backends (single-clause flattening applies to the empty clause). + for kt in [ + Tholder::Weighted(vec![]), + Tholder::Weighted(vec![vec![]]), + Tholder::Weighted(vec![vec![], vec![]]), + ] { + let event = InceptionEvent::new( + Identifier::Basic(prefixer([0; 32])), + Seqner::new(0), + saider([1; 32]), + vec![prefixer([2; 32])], + kt, + vec![saider([3; 32])], + Tholder::Simple(1), + vec![], + 0, + vec![], + vec![], + ); + assert_backends_identical(EventRef::Inception(&event)); + } + } + + #[test] + fn direct_render_into_prefilled_buffer_reports_absolute_slots() { + let event = build_ixn(((true, [0; 32]), 1, [1; 32], [2; 32], vec![])); + let placeholder = "#".repeat(44); + let mut buf = b"JUNK".to_vec(); + let layout = DirectJson + .render(EventRef::Interaction(&event), &placeholder, &mut buf) + .unwrap(); + assert_eq!(&buf[..4], b"JUNK", "render must append, not overwrite"); + assert_eq!(&buf[layout.size_slot], b"000000"); + assert_eq!(&buf[layout.said_slot], placeholder.as_bytes()); + assert!(layout.prefix_slot.is_none(), "ixn is single-SAID"); + } + + #[test] + fn direct_output_verifies_through_unchanged_read_path() { + let event = InceptionEvent::new( + Identifier::Basic(prefixer([0; 32])), + Seqner::new(0), + saider([1; 32]), + vec![prefixer([2; 32])], + Tholder::Simple(1), + vec![saider([3; 32])], + Tholder::Simple(1), + vec![prefixer([4; 32])], + 1, + vec![ConfigTrait::EstOnly], + vec![Seal::Digest { d: saider([5; 32]) }], + ); + let direct = serialize_with(&DirectJson, EventRef::Inception(&event)).unwrap(); + let parsed = deserialize_inception(direct.as_bytes()).unwrap(); + assert_eq!( + to_qb64_string(parsed.said()), + to_qb64_string(direct.said()), + "direct-rendered event must SAID-verify through the serde_json read path" + ); + } +} diff --git a/cesr/src/serder/serialize/drt.rs b/cesr/src/serder/serialize/drt.rs index 0dc76ea..1f073b3 100644 --- a/cesr/src/serder/serialize/drt.rs +++ b/cesr/src/serder/serialize/drt.rs @@ -1,7 +1,6 @@ //! Delegated rotation event (`drt`) serialization. -use crate::core::matter::code::DigestCode; -use crate::keri::{DelegatedRotationEvent, Ilk}; +use crate::keri::DelegatedRotationEvent; #[cfg(feature = "alloc")] #[allow( unused_imports, @@ -10,11 +9,13 @@ use crate::keri::{DelegatedRotationEvent, Ilk}; use alloc::{borrow::ToOwned, string::String, string::ToString, vec, vec::Vec}; use serde_json::{Map, Value}; -use super::{SerializedEvent, matters_to_json_array, seal_to_json, tholder_to_json}; +use super::{ + EventRef, SerdeJson, SerializedEvent, matters_to_json_array, seal_to_json, serialize_with, + tholder_to_json, +}; use crate::serder::error::SerderError; use crate::serder::primitives::{identifier_to_qb64_string, sn_to_hex, to_qb64_string}; -use crate::serder::said::{compute_digest, said_placeholder}; -use crate::serder::version::{VERSION_SIZE_MAX, VersionString}; +use crate::serder::version::VersionString; /// Serialize a [`DelegatedRotationEvent`] to canonical JSON with a computed SAID. /// @@ -32,9 +33,15 @@ use crate::serder::version::{VERSION_SIZE_MAX, VersionString}; pub fn serialize_delegated_rotation( event: &DelegatedRotationEvent, ) -> Result { - let digest_code = DigestCode::Blake3_256; - let placeholder = said_placeholder(digest_code)?; + serialize_with(&SerdeJson, EventRef::DelegatedRotation(event)) +} +/// Render the event body as canonical JSON with a zero-size version string +/// and `said_placeholder` in the `d` slot. +pub(crate) fn render_json( + event: &DelegatedRotationEvent, + said_placeholder: &str, +) -> Result { let rot = event.rotation(); let prefix_qb64 = identifier_to_qb64_string(rot.prefix()); let sn_hex = sn_to_hex(rot.sn().value()); @@ -67,35 +74,8 @@ pub fn serialize_delegated_rotation( anchors: &anchors_value, }; - let phase1_vs = VersionString::keri_json_v1().to_str()?; - let phase1_json = build_drt_json(&phase1_vs, &placeholder, &fields)?; - let measured_len = u32::try_from(phase1_json.len()) - .ok() - .filter(|len| *len <= VERSION_SIZE_MAX) - .ok_or(SerderError::VersionStringOverflow { - field: "size", - max: VERSION_SIZE_MAX, - })?; - - let vs_with_size = VersionString::keri_json_v1() - .with_size(measured_len) - .to_str()?; - let phase2_json = build_drt_json(&vs_with_size, &placeholder, &fields)?; - - let said = compute_digest(phase2_json.as_bytes(), digest_code)?; - let said_qb64 = to_qb64_string(&said); - - let final_json = build_drt_json(&vs_with_size, &said_qb64, &fields)?; - - let size = final_json.len(); - Ok(SerializedEvent { - raw: final_json.into_bytes(), - said, - prefix: None, - ilk: Ilk::Drt, - size, - event: (), - }) + let vs = VersionString::keri_json_v1().to_str()?; + build_drt_json(&vs, said_placeholder, &fields) } struct DrtFields<'a> { @@ -141,6 +121,7 @@ mod tests { use crate::core::matter::builder::MatterBuilder; use crate::core::matter::code::{DigestCode, VerKeyCode}; use crate::core::primitives::{Diger, Prefixer, Saider, Seqner, Tholder, Verfer}; + use crate::keri::Ilk; use crate::keri::RotationEvent; use alloc::borrow::Cow; diff --git a/cesr/src/serder/serialize/icp.rs b/cesr/src/serder/serialize/icp.rs index b095593..c829361 100644 --- a/cesr/src/serder/serialize/icp.rs +++ b/cesr/src/serder/serialize/icp.rs @@ -1,7 +1,6 @@ //! Inception event (`icp`) serialization. -use crate::core::matter::code::DigestCode; -use crate::keri::{Ilk, InceptionEvent}; +use crate::keri::InceptionEvent; #[cfg(feature = "alloc")] #[allow( unused_imports, @@ -10,11 +9,13 @@ use crate::keri::{Ilk, InceptionEvent}; use alloc::{borrow::ToOwned, string::String, string::ToString, vec, vec::Vec}; use serde_json::{Map, Value}; -use super::{SerializedEvent, matters_to_json_array, seal_to_json, tholder_to_json}; +use super::{ + EventRef, SerdeJson, SerializedEvent, matters_to_json_array, seal_to_json, serialize_with, + tholder_to_json, +}; use crate::serder::error::SerderError; -use crate::serder::primitives::{sn_to_hex, to_qb64_string}; -use crate::serder::said::{compute_digest, said_placeholder}; -use crate::serder::version::{VERSION_SIZE_MAX, VersionString}; +use crate::serder::primitives::sn_to_hex; +use crate::serder::version::VersionString; /// Serialize an [`InceptionEvent`] to canonical JSON with a computed SAID. /// @@ -28,9 +29,15 @@ use crate::serder::version::{VERSION_SIZE_MAX, VersionString}; /// Returns [`SerderError`] if CESR primitive encoding or digest computation /// fails. pub fn serialize_inception(event: &InceptionEvent) -> Result { - let digest_code = DigestCode::Blake3_256; - let placeholder = said_placeholder(digest_code)?; + serialize_with(&SerdeJson, EventRef::Inception(event)) +} +/// Render the event body as canonical JSON with a zero-size version string +/// and `said_placeholder` in both SAID slots (`d` and `i`). +pub(crate) fn render_json( + event: &InceptionEvent, + said_placeholder: &str, +) -> Result { let sn_hex = sn_to_hex(event.sn().value()); let kt = tholder_to_json(event.threshold()); let keys = matters_to_json_array(event.keys()); @@ -63,39 +70,8 @@ pub fn serialize_inception(event: &InceptionEvent) -> Result { @@ -139,6 +115,7 @@ mod tests { use crate::core::matter::code::{DigestCode, VerKeyCode}; use crate::core::primitives::{Diger, Prefixer, Saider, Seqner, Tholder, Verfer}; use crate::keri::ConfigTrait; + use crate::keri::Ilk; use alloc::borrow::Cow; fn make_prefixer() -> Prefixer<'static> { diff --git a/cesr/src/serder/serialize/ixn.rs b/cesr/src/serder/serialize/ixn.rs index 4781fb9..3722c42 100644 --- a/cesr/src/serder/serialize/ixn.rs +++ b/cesr/src/serder/serialize/ixn.rs @@ -1,7 +1,6 @@ //! Interaction event (`ixn`) serialization. -use crate::core::matter::code::DigestCode; -use crate::keri::{Ilk, InteractionEvent}; +use crate::keri::InteractionEvent; #[cfg(feature = "alloc")] #[allow( unused_imports, @@ -10,11 +9,10 @@ use crate::keri::{Ilk, InteractionEvent}; use alloc::{borrow::ToOwned, string::String, string::ToString, vec, vec::Vec}; use serde_json::{Map, Value}; -use super::{SerializedEvent, seal_to_json}; +use super::{EventRef, SerdeJson, SerializedEvent, seal_to_json, serialize_with}; use crate::serder::error::SerderError; use crate::serder::primitives::{identifier_to_qb64_string, sn_to_hex, to_qb64_string}; -use crate::serder::said::{compute_digest, said_placeholder}; -use crate::serder::version::{VERSION_SIZE_MAX, VersionString}; +use crate::serder::version::VersionString; /// Serialize an [`InteractionEvent`] to canonical JSON with a computed SAID. /// @@ -25,9 +23,15 @@ use crate::serder::version::{VERSION_SIZE_MAX, VersionString}; /// Returns [`SerderError`] if CESR primitive encoding or digest computation /// fails. pub fn serialize_interaction(event: &InteractionEvent) -> Result { - let digest_code = DigestCode::Blake3_256; - let placeholder = said_placeholder(digest_code)?; + serialize_with(&SerdeJson, EventRef::Interaction(event)) +} +/// Render the event body as canonical JSON with a zero-size version string +/// and `said_placeholder` in the `d` slot. +pub(crate) fn render_json( + event: &InteractionEvent, + said_placeholder: &str, +) -> Result { let prefix_qb64 = identifier_to_qb64_string(event.prefix()); let sn_hex = sn_to_hex(event.sn().value()); let prior_qb64 = to_qb64_string(event.prior_event_said()); @@ -45,39 +49,8 @@ pub fn serialize_interaction(event: &InteractionEvent) -> Result { @@ -109,7 +82,9 @@ mod tests { use crate::core::matter::builder::MatterBuilder; use crate::core::matter::code::{DigestCode, VerKeyCode}; use crate::core::primitives::{Prefixer, Saider, Seqner}; + use crate::keri::Ilk; use crate::keri::Seal; + use crate::serder::version::VERSION_SIZE_MAX; use alloc::borrow::Cow; fn make_prefixer() -> Prefixer<'static> { diff --git a/cesr/src/serder/serialize/rot.rs b/cesr/src/serder/serialize/rot.rs index 9cc1c80..9b1a330 100644 --- a/cesr/src/serder/serialize/rot.rs +++ b/cesr/src/serder/serialize/rot.rs @@ -1,7 +1,6 @@ //! Rotation event (`rot`) serialization. -use crate::core::matter::code::DigestCode; -use crate::keri::{Ilk, RotationEvent}; +use crate::keri::RotationEvent; #[cfg(feature = "alloc")] #[allow( unused_imports, @@ -10,11 +9,13 @@ use crate::keri::{Ilk, RotationEvent}; use alloc::{borrow::ToOwned, string::String, string::ToString, vec, vec::Vec}; use serde_json::{Map, Value}; -use super::{SerializedEvent, matters_to_json_array, seal_to_json, tholder_to_json}; +use super::{ + EventRef, SerdeJson, SerializedEvent, matters_to_json_array, seal_to_json, serialize_with, + tholder_to_json, +}; use crate::serder::error::SerderError; use crate::serder::primitives::{identifier_to_qb64_string, sn_to_hex, to_qb64_string}; -use crate::serder::said::{compute_digest, said_placeholder}; -use crate::serder::version::{VERSION_SIZE_MAX, VersionString}; +use crate::serder::version::VersionString; /// Serialize a [`RotationEvent`] to canonical JSON with a computed SAID. /// @@ -28,9 +29,15 @@ use crate::serder::version::{VERSION_SIZE_MAX, VersionString}; /// Returns [`SerderError`] if CESR primitive encoding or digest computation /// fails. pub fn serialize_rotation(event: &RotationEvent) -> Result { - let digest_code = DigestCode::Blake3_256; - let placeholder = said_placeholder(digest_code)?; + serialize_with(&SerdeJson, EventRef::Rotation(event)) +} +/// Render the event body as canonical JSON with a zero-size version string +/// and `said_placeholder` in the `d` slot. +pub(crate) fn render_json( + event: &RotationEvent, + said_placeholder: &str, +) -> Result { let prefix_qb64 = identifier_to_qb64_string(event.prefix()); let sn_hex = sn_to_hex(event.sn().value()); let prior_qb64 = to_qb64_string(event.prior_event_said()); @@ -62,35 +69,8 @@ pub fn serialize_rotation(event: &RotationEvent) -> Result { @@ -136,6 +116,7 @@ mod tests { use crate::core::matter::builder::MatterBuilder; use crate::core::matter::code::{DigestCode, VerKeyCode}; use crate::core::primitives::{Diger, Prefixer, Saider, Seqner, Tholder, Verfer}; + use crate::keri::Ilk; use alloc::borrow::Cow; fn make_prefixer() -> Prefixer<'static> { diff --git a/cesr/tests/serder_allocation.rs b/cesr/tests/serder_allocation.rs new file mode 100644 index 0000000..14c497f --- /dev/null +++ b/cesr/tests/serder_allocation.rs @@ -0,0 +1,129 @@ +//! Allocation-count safeguard for the #79 serialization backend seam. +//! +//! The direct backend exists to eliminate the `serde_json::Value` tree and +//! the intermediate `String` render from event serialization. That win is +//! behaviorally invisible — both backends produce byte-identical output — +//! so the cross-backend conformance tests cannot catch an allocation +//! regression. This test makes the allocation *count* an observable, +//! asserted invariant: the direct backend must allocate strictly less than +//! the `serde_json` reference for the same event. +//! +//! Mirrors the counting-allocator convention of `tests/allocation.rs` +//! (thread-local counters, separate test binary so the global allocator +//! does not interfere with other suites). +#![cfg(feature = "serder")] +#![allow( + clippy::unwrap_used, + reason = "integration test binary — entirely test code, same convention as \ + #[cfg(test)] mod tests in src/, which use unwrap() to document the \ + invariant that fails" +)] + +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 core::cell::Cell; +use std::alloc::{GlobalAlloc, Layout, System}; + +thread_local! { + static COUNT: Cell = const { Cell::new(0) }; +} + +struct Counting; + +#[allow( + unsafe_code, + reason = "test-only global allocator; crate's no-unsafe rule applies to src/, not tests/" +)] +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let _ = COUNT.try_with(|c| c.set(c.get() + 1)); + unsafe { System.alloc(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + let _ = COUNT.try_with(|c| c.set(c.get() + 1)); + unsafe { System.realloc(ptr, layout, new_size) } + } +} + +#[global_allocator] +static GLOBAL: Counting = Counting; + +fn measure(f: impl FnOnce() -> T) -> (T, usize) { + let c0 = COUNT.with(Cell::get); + let result = f(); + (result, COUNT.with(Cell::get) - c0) +} + +fn prefixer(byte: u8) -> Prefixer<'static> { + MatterBuilder::new() + .with_code(VerKeyCode::Ed25519) + .with_raw(vec![byte; 32]) + .unwrap() + .build() + .unwrap() +} + +fn saider(byte: u8) -> Saider<'static> { + MatterBuilder::new() + .with_code(DigestCode::Blake3_256) + .with_raw(vec![byte; 32]) + .unwrap() + .build() + .unwrap() +} + +fn fixture_icp() -> InceptionEvent { + InceptionEvent::new( + Identifier::Basic(prefixer(0)), + Seqner::new(0), + saider(1), + vec![prefixer(2), prefixer(3)], + Tholder::Simple(2), + vec![saider(4), saider(5)], + Tholder::Simple(2), + vec![prefixer(6)], + 1, + vec![ConfigTrait::EstOnly], + vec![ + Seal::Digest { d: saider(7) }, + Seal::Source { + s: Seqner::new(3), + d: saider(8), + }, + ], + ) +} + +#[test] +fn direct_backend_allocates_strictly_less_than_serde_json() { + let event = fixture_icp(); + + // Warm both paths once so lazy one-time setup does not skew the deltas. + let _ = serialize_with(&SerdeJson, EventRef::Inception(&event)).unwrap(); + let _ = serialize_with(&DirectJson, EventRef::Inception(&event)).unwrap(); + + let (reference, serde_allocs) = + measure(|| serialize_with(&SerdeJson, EventRef::Inception(&event)).unwrap()); + let (direct, direct_allocs) = + measure(|| serialize_with(&DirectJson, EventRef::Inception(&event)).unwrap()); + + assert_eq!( + reference.as_bytes(), + direct.as_bytes(), + "sanity: backends must agree before comparing their allocation counts" + ); + assert!( + direct_allocs < serde_allocs, + "direct backend must allocate strictly less than the serde_json reference; \ + got direct={direct_allocs} vs serde_json={serde_allocs} — a regression \ + reintroduced an intermediate tree or render" + ); +}