Skip to main content

safemesh_crdt/
codec.rs

1// SafeMesh — delta-state CRDT convergence, built on crdt-lean.
2// Copyright (C) 2026 Ben Cassie
3// SPDX-License-Identifier: Apache-2.0
4
5use crate::{
6    Admission, Crdt, EnableWinsFlag, EnableWinsFlagDelta, EventLog, GCounterDelta, GSet, LwwMap,
7    LwwMapDelta, LwwRegister, LwwRegisterDelta, OrSet, OrSetDelta, PnCounterDelta, Record, Rga,
8    RgaDelta, VersionVector,
9};
10use alloc::{borrow::Cow, string::String, vec::Vec};
11
12pub(super) const TAG_RECORD: u8 = 0x01;
13/// Unchecked EventLog frame written before the CRC envelope; decoded only by migration.
14pub(super) const TAG_EVENT_LOG_LEGACY: u8 = 0x02;
15pub(super) const TAG_EVENT_LOG: u8 = 0x03;
16pub(super) const TAG_VERSION_VECTOR: u8 = 0x04;
17pub(super) const TAG_GCOUNTER_DELTA: u8 = 0x10;
18pub(super) const TAG_PNCOUNTER_INC: u8 = 0x11;
19pub(super) const TAG_PNCOUNTER_DEC: u8 = 0x12;
20pub(super) const TAG_GSET_U64: u8 = 0x20;
21pub(super) const TAG_ORSET_U64: u8 = 0x30;
22pub(super) const TAG_ORSET_STRING: u8 = 0x35;
23pub(super) const TAG_ORSET_ADD_U64: u8 = 0x31;
24pub(super) const TAG_ORSET_REMOVE_U64: u8 = 0x32;
25pub(super) const TAG_ORSET_ADD_STRING: u8 = 0x33;
26pub(super) const TAG_ORSET_REMOVE_STRING: u8 = 0x34;
27pub(super) const TAG_RGA_U64: u8 = 0x40;
28pub(super) const TAG_RGA_INSERT_U64: u8 = 0x41;
29pub(super) const TAG_RGA_DELETE_U64: u8 = 0x42;
30pub(super) const TAG_LWW_REGISTER_DELTA_U64: u8 = 0x50;
31pub(super) const TAG_LWW_REGISTER_U64: u8 = 0x51;
32pub(super) const TAG_ENABLE_WINS_FLAG_ENABLE_U64: u8 = 0x60;
33pub(super) const TAG_ENABLE_WINS_FLAG_DISABLE_U64: u8 = 0x61;
34pub(super) const TAG_ENABLE_WINS_FLAG_U64: u8 = 0x62;
35pub(super) const TAG_LWW_MAP_SET_U64: u8 = 0x70;
36pub(super) const TAG_LWW_MAP_REMOVE_U64: u8 = 0x71;
37pub(super) const TAG_LWW_MAP_U64: u8 = 0x72;
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40#[non_exhaustive]
41pub enum WireError {
42    OwnershipViolation,
43    /// A sequence-0 record for a carrier without a more specific zero error.
44    ZeroSequenceRecord {
45        replica: u64,
46        kind: &'static str,
47    },
48    /// An OR-Set add record at sequence 0. An add mints a token at its record
49    /// ID, and the Lean ownership rule allocates tokens only at positive
50    /// sequences (`RecordKernel.allocateToken`, `RecordKernel.permitted`).
51    ZeroSequenceAdd {
52        /// Author of the refused record. Its sequence is 0.
53        replica: u64,
54    },
55    /// An OR-Set remove record at sequence 0, forbidden by RecordKernel.permitted.
56    ZeroSequenceRemove {
57        /// Author of the refused record. Its sequence is 0.
58        replica: u64,
59    },
60    UnexpectedEof,
61    InvalidTag,
62    TrailingBytes,
63    LengthOverflow,
64    RecordCollision,
65    DuplicateEntry,
66    /// A state entry list is not in ascending encoder order.
67    NonCanonicalOrder,
68    IntegrityMismatch,
69    InvalidUtf8,
70    /// A fixed-domain log constructed or saved without its arity.
71    MissingShape,
72    /// A valid EventLog frame from an earlier encoder. Nothing was decoded;
73    /// migrate it once with [`EventLog::migrate_legacy_wire_bytes_for`].
74    /// For tag 0x02, check an old-file checksum or compare a second copy first;
75    /// see that method's warning about undetected changed bits.
76    LegacyEventLogFrame {
77        found: LegacyFrame,
78    },
79    DeltaTypeMismatch,
80    ReplicaCountMismatch {
81        expected: usize,
82        actual: usize,
83    },
84    /// Fixed and unbounded replica domains are incompatible.
85    ArityKindMismatch,
86    VersionAuthorLimitExceeded {
87        max_authors: usize,
88    },
89    VersionZeroReplicaLimitExceeded {
90        max_zero_replicas: usize,
91    },
92    CollectionElementLimitExceeded {
93        max_elements: usize,
94    },
95    NestedRecordLimitExceeded {
96        max_records: usize,
97    },
98    RecordLimitExceeded {
99        max_records: usize,
100    },
101    /// An omitted limit reached the default record budget.
102    DefaultRecordLimitExceeded {
103        max_records: usize,
104    },
105    NonCanonicalVersionVector,
106}
107
108impl core::fmt::Display for WireError {
109    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
110        match self {
111            Self::OwnershipViolation => {
112                f.write_str("record violates replica ownership or coordinate bounds")
113            }
114            Self::ZeroSequenceRecord { replica, kind } => write!(
115                f,
116                "{kind} record (replica {replica}, sequence 0) refused: record sequences start at 1. \
117                 Recovery: remove this record from any stored log, issue the edit again from replica {replica} \
118                 at a positive sequence, and write the log again"
119            ),
120            Self::ZeroSequenceAdd { replica } => write!(
121                f,
122                "OR-Set add record (replica {replica}, sequence 0) refused: add sequences start at 1. \
123                 Recovery: re-add the element from replica {replica} at a positive sequence, \
124                 and remove this record from any stored log before loading it again"
125            ),
126            Self::ZeroSequenceRemove { replica } => write!(
127                f,
128                "OR-Set remove record (replica {replica}, sequence 0) refused: remove sequences start at 1. \
129                 Recovery: remove this record from any stored log, issue the remove again from replica {replica} \
130                 at a positive sequence, and write the log again"
131            ),
132            Self::UnexpectedEof => f.write_str("unexpected end of wire input"),
133            Self::InvalidTag => f.write_str("unexpected wire tag"),
134            Self::TrailingBytes => f.write_str("unexpected trailing bytes after wire value"),
135            Self::LengthOverflow => f.write_str("wire length exceeds the representable range"),
136            Self::RecordCollision => f.write_str("record identity has conflicting payloads"),
137            Self::DuplicateEntry => {
138                f.write_str("wire state contains a duplicate entry")
139            }
140            Self::NonCanonicalOrder => f.write_str("wire state entries are not in ascending order"),
141            Self::IntegrityMismatch => f.write_str("wire frame integrity check failed"),
142            Self::InvalidUtf8 => f.write_str("wire string contains invalid UTF-8"),
143            Self::MissingShape => f.write_str("wire frame is missing required shape metadata"),
144            Self::LegacyEventLogFrame { found } => write!(
145                f,
146                "legacy EventLog frame: found {found}, expected tag 0x03 with shape header; \
147                 migrate once with EventLog::migrate_legacy_wire_bytes_for(bytes, &destination) \
148                 or `cargo run -p safemesh-crdt --example migrate_event_log`, giving the \
149                 original replica count (safemesh-crdt README, \"Migrating a legacy EventLog\")"
150            ),
151            Self::DeltaTypeMismatch => {
152                f.write_str("wire delta schema does not match the expected type")
153            }
154            Self::ReplicaCountMismatch { expected, actual } => write!(
155                f,
156                "replica-count mismatch: expected={expected}, actual={actual}"
157            ),
158            Self::ArityKindMismatch => {
159                f.write_str("invalid or incompatible replica-domain arity kind")
160            }
161            Self::VersionAuthorLimitExceeded { max_authors } => {
162                write!(f, "wire version exceeds author limit {max_authors}")
163            }
164            Self::VersionZeroReplicaLimitExceeded { max_zero_replicas } => {
165                write!(
166                    f,
167                    "wire version exceeds zero-acknowledgement limit {max_zero_replicas}"
168                )
169            }
170            Self::CollectionElementLimitExceeded { max_elements } => {
171                write!(f, "wire collection exceeds element limit {max_elements}")
172            }
173            Self::NestedRecordLimitExceeded { max_records } => {
174                write!(f, "nested wire log exceeds record limit {max_records}")
175            }
176            Self::RecordLimitExceeded { max_records } => {
177                write!(f, "RecordLimitExceeded: {max_records}")
178            }
179            Self::DefaultRecordLimitExceeded { max_records } => {
180                write!(f, "RecordLimitExceeded: {max_records}; pass DecodeLimits::UNLIMITED to retry")
181            }
182            Self::NonCanonicalVersionVector => f.write_str("noncanonical wire version vector"),
183        }
184    }
185}
186
187impl core::error::Error for WireError {}
188
189/// An EventLog frame layout written by an earlier encoder, named by
190/// [`WireError::LegacyEventLogFrame`].
191#[derive(Clone, Copy, Debug, PartialEq, Eq)]
192pub enum LegacyFrame {
193    /// Tag `0x02`: record count and records, with no length pair, CRC or shape.
194    Tag02,
195    /// Tag `0x03` with the CRC envelope but no shape header (schema and arity).
196    Tag03Unshaped,
197}
198
199impl core::fmt::Display for LegacyFrame {
200    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
201        f.write_str(match self {
202            Self::Tag02 => "tag 0x02 (no CRC, no shape header)",
203            Self::Tag03Unshaped => "tag 0x03 without shape header",
204        })
205    }
206}
207
208/// Optional count budget for each built-in collection and collection delta.
209#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
210pub struct CollectionLimits {
211    /// Maximum declared count per collection. `None` removes the ceiling.
212    pub max_elements: Option<usize>,
213}
214
215impl CollectionLimits {
216    /// Default collection wire ceiling; callers may explicitly raise it.
217    pub const WIRE_DEFAULT: Self = Self {
218        max_elements: Some(4096),
219    };
220}
221
222/// Record and collection limits for [`EventLog::from_wire_bytes_with_limits`].
223#[derive(Clone, Copy, Debug, PartialEq, Eq)]
224pub struct DecodeLimits {
225    /// Maximum record occurrences in each EventLog frame, including nested
226    /// frames and duplicates. The default is [`Self::DEFAULT_MAX_RECORDS`];
227    /// `None` is explicit unlimited, and `Some(0)` admits only empty logs.
228    /// This does not bound bytes or collection entries in a payload.
229    pub max_records: Option<usize>,
230    /// Maximum elements in each nested built-in collection. `None` uses
231    /// the default ceiling of 4,096; use `Some(n)` to set an explicit ceiling.
232    pub max_collection_elements: Option<usize>,
233}
234
235impl DecodeLimits {
236    /// The default record budget for peer and persisted log frames.
237    pub const DEFAULT_MAX_RECORDS: usize = 100_000;
238    /// Explicitly retain the previous unbounded record decoding behavior.
239    pub const UNLIMITED: Self = Self {
240        max_records: None,
241        max_collection_elements: None,
242    };
243}
244
245impl Default for DecodeLimits {
246    fn default() -> Self {
247        Self {
248            max_records: Some(Self::DEFAULT_MAX_RECORDS),
249            max_collection_elements: None,
250        }
251    }
252}
253
254/// Failure from the bounded event-log decoder.
255/// Separate from [`WireError`] to preserve existing exhaustive matches.
256#[derive(Clone, Copy, Debug, PartialEq, Eq)]
257pub enum DecodeError {
258    Wire(WireError),
259    /// Stopped before decoding the next record; no partial log is returned.
260    RecordLimitExceeded {
261        max_records: usize,
262    },
263    /// The caller omitted the record budget; bindings may name their own escape.
264    DefaultRecordLimitExceeded {
265        max_records: usize,
266    },
267}
268
269impl DecodeError {
270    /// Preserve the refusal kind while recording that the caller used the default.
271    pub fn with_default_record_limit(self) -> Self {
272        match self {
273            Self::RecordLimitExceeded { max_records } => {
274                Self::DefaultRecordLimitExceeded { max_records }
275            }
276            other => other,
277        }
278    }
279}
280
281impl From<WireError> for DecodeError {
282    fn from(error: WireError) -> Self {
283        Self::Wire(error)
284    }
285}
286
287impl From<DecodeError> for WireError {
288    fn from(error: DecodeError) -> Self {
289        match error {
290            DecodeError::Wire(error) => error,
291            DecodeError::RecordLimitExceeded { max_records } => {
292                Self::RecordLimitExceeded { max_records }
293            }
294            DecodeError::DefaultRecordLimitExceeded { max_records } => {
295                Self::DefaultRecordLimitExceeded { max_records }
296            }
297        }
298    }
299}
300
301/// Stable, versioned identity for persisted payloads, independent of Rust names.
302/// External implementations must use a globally unique schema and change it when
303/// the wire interpretation changes. Never reuse a built-in `safemesh/` identity.
304pub trait WireSchema {
305    /// Borrow fixed identities; return owned bytes for composed schemas.
306    fn wire_schema() -> Cow<'static, [u8]>;
307    const REQUIRES_ARITY: bool = false;
308}
309
310macro_rules! wire_schema {
311    ($ty:ty, $name:literal, $fixed:expr) => {
312        impl WireSchema for $ty {
313            fn wire_schema() -> Cow<'static, [u8]> {
314                Cow::Borrowed($name.as_bytes())
315            }
316            const REQUIRES_ARITY: bool = $fixed;
317        }
318    };
319}
320
321wire_schema!(GCounterDelta, "safemesh/gcounter-delta/v1", true);
322wire_schema!(PnCounterDelta, "safemesh/pncounter-delta/v1", true);
323wire_schema!(GSet<u64>, "safemesh/gset-u64/v1", false);
324wire_schema!(OrSetDelta<u64, u64>, "safemesh/orset-delta-u64-u64/v1", false);
325wire_schema!(OrSetDelta<String, u64>, "safemesh/orset-delta-utf8-u64/v1", false);
326wire_schema!(OrSet<u64, u64>, "safemesh/orset-u64-u64/v1", false);
327wire_schema!(OrSet<String, u64>, "safemesh/orset-utf8-u64/v1", false);
328wire_schema!(Rga<u64, u64>, "safemesh/rga-u64-u64/v1", false);
329wire_schema!(RgaDelta<u64, u64>, "safemesh/rga-delta-u64-u64/v1", false);
330wire_schema!(VersionVector, "safemesh/version-vector/v1", false);
331wire_schema!(
332    LwwRegisterDelta<u64>,
333    "safemesh/lww-register-delta-u64/v1",
334    false
335);
336wire_schema!(LwwRegister<u64>, "safemesh/lww-register-u64/v1", false);
337wire_schema!(
338    EnableWinsFlagDelta<u64>,
339    "safemesh/enable-wins-flag-delta-u64/v1",
340    false
341);
342wire_schema!(
343    EnableWinsFlag<u64>,
344    "safemesh/enable-wins-flag-u64/v1",
345    false
346);
347wire_schema!(LwwMapDelta<u64, u64>, "safemesh/lww-map-delta-u64-u64/v1", false);
348wire_schema!(LwwMap<u64, u64>, "safemesh/lww-map-u64-u64/v1", false);
349
350impl<D: WireSchema> WireSchema for Record<D> {
351    fn wire_schema() -> Cow<'static, [u8]> {
352        let mut schema = b"safemesh/record/v1/".to_vec();
353        schema.extend_from_slice(D::wire_schema().as_ref());
354        Cow::Owned(schema)
355    }
356}
357
358impl<D: WireSchema> WireSchema for EventLog<D> {
359    fn wire_schema() -> Cow<'static, [u8]> {
360        let mut schema = b"safemesh/event-log/v2/".to_vec();
361        schema.extend_from_slice(D::wire_schema().as_ref());
362        Cow::Owned(schema)
363    }
364}
365
366impl<D: WireDecode + WireSchema + PartialEq> EventLog<D> {
367    /// Decode an inert log with an optional top-level record budget.
368    ///
369    /// Checks frame integrity and shape first, then checks the declared record
370    /// count before it reads any record. Duplicate occurrences count toward it.
371    /// Returns [`DecodeError::RecordLimitExceeded`] without a partial result.
372    /// CRC verification still scans the entire frame; callers must separately
373    /// cap input bytes and payload complexity.
374    /// As with [`WireDecode::from_wire_bytes`], this does not validate a destination
375    /// CRDT for replay. The option is currently exposed only in Rust.
376    ///
377    /// [`DecodeLimits::default`] admits at most its named record budget.
378    ///
379    /// ```
380    /// use safemesh_crdt::{DecodeError, DecodeLimits, EventLog, GSet, Record, RecordId};
381    /// let records = [Record { id: RecordId { replica: 0, sequence: 1 }, delta: GSet::<u64>::new() }];
382    /// let mut bytes = Vec::new();
383    /// EventLog::encode_records(None, &records, &mut bytes).unwrap();
384    /// assert_eq!(EventLog::<GSet<u64>>::from_wire_bytes_with_limits(
385    ///     &bytes, DecodeLimits { max_records: Some(0), ..DecodeLimits::default() }),
386    ///     Err(DecodeError::RecordLimitExceeded { max_records: 0 }));
387    /// let log = EventLog::<GSet<u64>>::from_wire_bytes_with_limits(
388    ///     &bytes, DecodeLimits { max_records: Some(1), max_collection_elements: Some(5_000) }).unwrap();
389    /// assert_eq!(log.records().len(), 1);
390    /// ```
391    pub fn from_wire_bytes_with_limits(
392        bytes: &[u8],
393        limits: DecodeLimits,
394    ) -> Result<Self, DecodeError> {
395        let mut cursor = WireCursor::new(bytes);
396        let log = Self::decode_with(
397            &mut cursor,
398            CollectionLimits {
399                max_elements: limits
400                    .max_collection_elements
401                    .or(CollectionLimits::WIRE_DEFAULT.max_elements),
402            },
403            limits.max_records,
404            |_| {},
405            |index| {
406                if let Some(max_records) = limits.max_records {
407                    if index >= max_records {
408                        return Err(DecodeError::RecordLimitExceeded { max_records });
409                    }
410                }
411                Ok(())
412            },
413        )?;
414        if !cursor.is_empty() {
415            return Err(WireError::TrailingBytes.into());
416        }
417        Ok(log)
418    }
419
420    /// Decode and compare the saved shape with the destination before replay.
421    /// Plain `from_wire_bytes` decodes a log and retains its domain; it does not
422    /// load a CRDT. Use this method at every persisted-state loading boundary.
423    pub fn from_wire_bytes_for<C: Crdt<Delta = D>>(
424        bytes: &[u8],
425        state: &C,
426    ) -> Result<Self, WireError> {
427        let log = Self::from_wire_bytes_with_limits(bytes, DecodeLimits::default())
428            .map_err(|error| WireError::from(error.with_default_record_limit()))?;
429        log.validate_for(state)?;
430        Ok(log)
431    }
432
433    /// Decode with record and nested collection budgets, then validate the
434    /// saved shape and records against the destination before replay.
435    pub fn from_wire_bytes_for_with_limits<C: Crdt<Delta = D>>(
436        bytes: &[u8],
437        state: &C,
438        limits: DecodeLimits,
439    ) -> Result<Self, DecodeError> {
440        let log = Self::from_wire_bytes_with_limits(bytes, limits)?;
441        log.validate_for(state)?;
442        Ok(log)
443    }
444
445    /// Decode all input occurrences in order for per-record admission reporting.
446    /// Validate the entire frame, collisions and destination shape before returning.
447    /// The ordinary log loaders continue to deduplicate identical records.
448    pub fn records_from_wire_bytes_for<C: Crdt<Delta = D>>(
449        bytes: &[u8],
450        state: &C,
451    ) -> Result<Vec<Record<D>>, WireError>
452    where
453        D: Clone,
454    {
455        Self::records_from_wire_bytes_for_with_limits(bytes, state, DecodeLimits::default())
456            .map_err(|error| WireError::from(error.with_default_record_limit()))
457    }
458
459    /// Decode each occurrence with record and nested collection budgets, then
460    /// validate the destination before returning the occurrences.
461    pub fn records_from_wire_bytes_for_with_limits<C: Crdt<Delta = D>>(
462        bytes: &[u8],
463        state: &C,
464        limits: DecodeLimits,
465    ) -> Result<Vec<Record<D>>, DecodeError>
466    where
467        D: Clone,
468    {
469        let mut records = Vec::new();
470        let mut cursor = WireCursor::new(bytes);
471        let log = Self::decode_with(
472            &mut cursor,
473            CollectionLimits {
474                max_elements: limits
475                    .max_collection_elements
476                    .or(CollectionLimits::WIRE_DEFAULT.max_elements),
477            },
478            limits.max_records,
479            |record| records.push(record.clone()),
480            |index| {
481                if let Some(max_records) = limits.max_records {
482                    if index >= max_records {
483                        return Err(DecodeError::RecordLimitExceeded { max_records });
484                    }
485                }
486                Ok(())
487            },
488        )?;
489        if !cursor.is_empty() {
490            return Err(WireError::TrailingBytes.into());
491        }
492        log.validate_for(state)?;
493        Ok(records)
494    }
495
496    fn validate_for<C: Crdt<Delta = D>>(&self, state: &C) -> Result<(), WireError> {
497        self.validate_shape(state)?;
498        for record in self.records() {
499            state.validate_record(record.id, &record.delta)?;
500        }
501        Ok(())
502    }
503}
504
505impl<D: WireDecode + WireEncode + WireSchema + PartialEq> EventLog<D> {
506    /// Re-encode a legacy EventLog frame ([`LegacyFrame`]) as a current shaped
507    /// frame. This is explicit; no loader ever migrates automatically.
508    ///
509    /// Old frames record neither schema nor arity. The caller declares both:
510    /// the schema by choosing `D`, and the arity through `state`, a destination
511    /// carrier with the original replica count, verified independently. Do not
512    /// infer it from the largest coordinate in the log. Every record is decoded
513    /// and deduplicated as the old decoder did, then validated against `state`.
514    /// Record bytes and order are unchanged; only the frame gains its shape. Any
515    /// error returns no output. A current frame is validated the same way and
516    /// returned unchanged, so a repeated migration has no further effect.
517    /// Nested EventLog payloads are not rewritten; a legacy nested frame fails.
518    ///
519    /// A tag 0x02 frame has no checksum. Migration accepts a frame with a
520    /// changed payload bit when it still decodes, and the migrated value can
521    /// differ from the original. Before migration, check your own checksum of
522    /// the old file or compare it with a second copy. A tag 0x03 frame has a
523    /// CRC and refuses the same kind of changed payload.
524    ///
525    /// ```
526    /// use safemesh_crdt::{EventLog, GCounter, GCounterDelta, LegacyFrame, WireDecode, WireError};
527    /// let old = [0x02, 0, 0, 0, 0]; // an empty tag 0x02 log
528    /// assert_eq!(EventLog::<GCounterDelta>::from_wire_bytes(&old),
529    ///     Err(WireError::LegacyEventLogFrame { found: LegacyFrame::Tag02 }));
530    /// let state = GCounter::new(2);
531    /// let new = EventLog::migrate_legacy_wire_bytes_for(&old, &state)?;
532    /// assert_eq!(EventLog::from_wire_bytes_for(&new, &state)?.replica_count(), Some(2));
533    /// # Ok::<(), WireError>(())
534    /// ```
535    pub fn migrate_legacy_wire_bytes_for<C: Crdt<Delta = D>>(
536        bytes: &[u8],
537        state: &C,
538    ) -> Result<Vec<u8>, WireError> {
539        match Self::migrate_legacy_wire_bytes_for_with_limits(bytes, state, DecodeLimits::default())
540        {
541            Ok(bytes) => Ok(bytes),
542            Err(DecodeError::Wire(error)) => Err(error),
543            Err(DecodeError::RecordLimitExceeded { max_records })
544            | Err(DecodeError::DefaultRecordLimitExceeded { max_records }) => {
545                Err(WireError::DefaultRecordLimitExceeded { max_records })
546            }
547        }
548    }
549
550    /// Migrate a legacy frame with a budget for each input record occurrence.
551    /// Duplicate records count; the limit is checked before decoding the next record.
552    /// A current shaped frame uses the same limited decoder as ordinary loading.
553    /// The tag 0x02 checksum warning and pre-migration checks in
554    /// [`Self::migrate_legacy_wire_bytes_for`] apply here too.
555    pub fn migrate_legacy_wire_bytes_for_with_limits<C: Crdt<Delta = D>>(
556        bytes: &[u8],
557        state: &C,
558        limits: DecodeLimits,
559    ) -> Result<Vec<u8>, DecodeError> {
560        let mut cursor = WireCursor::new(bytes);
561        let mut body = match cursor.read_u8()? {
562            TAG_EVENT_LOG_LEGACY => cursor,
563            TAG_EVENT_LOG => {
564                let body = read_checked_body(&mut cursor)?;
565                if !cursor.is_empty() {
566                    return Err(WireError::TrailingBytes.into());
567                }
568                if WireCursor::new(body).read_u32()? == u32::MAX {
569                    Self::from_wire_bytes_for_with_limits(bytes, state, limits)?;
570                    return Ok(bytes.to_vec());
571                }
572                WireCursor::new(body)
573            }
574            _ => return Err(WireError::InvalidTag.into()),
575        };
576        let mut log = EventLog::for_crdt(state);
577        for index in 0..body.read_len()? {
578            if let Some(max_records) = limits.max_records {
579                if index >= max_records {
580                    return Err(DecodeError::RecordLimitExceeded { max_records });
581                }
582            }
583            let record_len = body.read_len()?;
584            let mut record_cursor = WireCursor::new(body.read_exact(record_len)?);
585            let record = Record::<D>::decode_wire_with_limits(
586                &mut record_cursor,
587                CollectionLimits {
588                    max_elements: limits
589                        .max_collection_elements
590                        .or(CollectionLimits::WIRE_DEFAULT.max_elements),
591                },
592                limits.max_records,
593            )?;
594            if !record_cursor.is_empty() {
595                return Err(WireError::TrailingBytes.into());
596            }
597            match log.identity_admission(&record) {
598                Admission::Collision => return Err(WireError::RecordCollision.into()),
599                Admission::Accepted => log.commit_record(record),
600                Admission::Duplicate => {}
601                Admission::Invalid(_) => unreachable!("identity check does not validate a carrier"),
602            }
603        }
604        if !body.is_empty() {
605            return Err(WireError::TrailingBytes.into());
606        }
607        log.validate_for(state)?;
608        Ok(log.to_wire_bytes()?)
609    }
610}
611
612/// Encode a payload using the canonical wire primitives.
613///
614/// Custom payloads choose their own layout and, for persistence, a unique [`WireSchema`].
615pub trait WireEncode {
616    fn encode_wire(&self, out: &mut Vec<u8>) -> Result<(), WireError>;
617
618    fn to_wire_bytes(&self) -> Result<Vec<u8>, WireError> {
619        let mut out = Vec::new();
620        self.encode_wire(&mut out)?;
621        Ok(out)
622    }
623}
624
625/// Decode one payload from a shared cursor using its public read methods.
626/// [`Self::from_wire_bytes`] additionally rejects trailing bytes.
627///
628/// ```
629/// use safemesh_crdt::{WireCursor, WireDecode, WireEncode, WireError};
630/// #[derive(Debug, PartialEq)]
631/// struct Reading(u64);
632/// impl WireEncode for Reading {
633///     fn encode_wire(&self, out: &mut Vec<u8>) -> Result<(), WireError> {
634///         out.extend_from_slice(&self.0.to_le_bytes());
635///         Ok(())
636///     }
637/// }
638/// impl WireDecode for Reading {
639///     fn decode_wire(cursor: &mut WireCursor<'_>) -> Result<Self, WireError> {
640///         Ok(Self(cursor.read_u64()?))
641///     }
642/// }
643/// let reading = Reading(42);
644/// assert_eq!(Reading::from_wire_bytes(&reading.to_wire_bytes()?)?, reading);
645/// # Ok::<(), WireError>(())
646/// ```
647pub trait WireDecode: Sized {
648    fn decode_wire(cursor: &mut WireCursor<'_>) -> Result<Self, WireError>;
649
650    /// Decode a nested payload with a collection budget. Other payload types
651    /// retain their ordinary decoder unless they implement this method.
652    fn decode_wire_with_collection_limits(
653        cursor: &mut WireCursor<'_>,
654        _limits: CollectionLimits,
655    ) -> Result<Self, WireError> {
656        Self::decode_wire(cursor)
657    }
658
659    /// Decode a payload nested in an EventLog with its record budget.
660    fn decode_wire_with_limits(
661        cursor: &mut WireCursor<'_>,
662        collections: CollectionLimits,
663        _max_records: Option<usize>,
664    ) -> Result<Self, WireError> {
665        Self::decode_wire_with_collection_limits(cursor, collections)
666    }
667
668    /// Decode a complete wire value with an explicit collection ceiling.
669    fn from_wire_bytes_with_collection_limits(
670        bytes: &[u8],
671        limits: CollectionLimits,
672    ) -> Result<Self, WireError> {
673        let mut cursor = WireCursor::new(bytes);
674        let value = Self::decode_wire_with_collection_limits(&mut cursor, limits)?;
675        if !cursor.is_empty() {
676            return Err(WireError::TrailingBytes);
677        }
678        Ok(value)
679    }
680
681    fn from_wire_bytes(bytes: &[u8]) -> Result<Self, WireError> {
682        let mut cursor = WireCursor::new(bytes);
683        let value = Self::decode_wire(&mut cursor)?;
684        if cursor.is_empty() {
685            Ok(value)
686        } else {
687            Err(WireError::TrailingBytes)
688        }
689    }
690}
691
692impl<D: WireDecode> Record<D> {
693    /// Decode a record with an explicit budget for nested G-Set or RGA state.
694    pub fn from_wire_bytes_with_collection_limits(
695        bytes: &[u8],
696        limits: CollectionLimits,
697    ) -> Result<Self, WireError> {
698        let mut cursor = WireCursor::new(bytes);
699        let record = Self::decode_wire_with_collection_limits(&mut cursor, limits)?;
700        if !cursor.is_empty() {
701            return Err(WireError::TrailingBytes);
702        }
703        Ok(record)
704    }
705}
706
707/// Borrowed, bounds-checked reader for canonical wire primitives.
708///
709/// Integers are little-endian; lengths occupy an unsigned 32-bit field.
710/// These widths, byte order and documented consumption/error semantics are
711/// public compatibility commitments. Storage and bounds-checking internals
712/// remain private; custom payload schemas define their own interpretation.
713/// Failed byte reads leave the cursor unchanged. Conversion failures in
714/// [`Self::read_len`] consume the length field.
715pub struct WireCursor<'a> {
716    bytes: &'a [u8],
717    offset: usize,
718}
719
720impl<'a> WireCursor<'a> {
721    pub fn new(bytes: &'a [u8]) -> Self {
722        WireCursor { bytes, offset: 0 }
723    }
724
725    pub fn is_empty(&self) -> bool {
726        self.offset == self.bytes.len()
727    }
728
729    /// Read one byte, or return `UnexpectedEof` without advancing.
730    pub fn read_u8(&mut self) -> Result<u8, WireError> {
731        let byte = *self
732            .bytes
733            .get(self.offset)
734            .ok_or(WireError::UnexpectedEof)?;
735        self.offset += 1;
736        Ok(byte)
737    }
738
739    /// Read four bytes as a little-endian `u32`, or return `UnexpectedEof` without advancing.
740    pub fn read_u32(&mut self) -> Result<u32, WireError> {
741        let bytes = self.read_exact(4)?;
742        Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
743    }
744
745    /// Read eight bytes as a little-endian `u64`, or return `UnexpectedEof` without advancing.
746    pub fn read_u64(&mut self) -> Result<u64, WireError> {
747        let bytes = self.read_exact(8)?;
748        Ok(u64::from_le_bytes([
749            bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
750        ]))
751    }
752
753    /// Read a little-endian `u32` length and convert it to `usize`.
754    /// Returns `UnexpectedEof` without advancing for a truncated field.
755    /// If the value cannot fit `usize`, consumes four bytes and returns `LengthOverflow`.
756    pub fn read_len(&mut self) -> Result<usize, WireError> {
757        usize::try_from(self.read_u32()?).map_err(|_| WireError::LengthOverflow)
758    }
759
760    /// Borrow and consume exactly `len` bytes, including zero bytes.
761    /// Returns `LengthOverflow` if offset + len overflows, otherwise
762    /// `UnexpectedEof` if the range exceeds the input. Both leave the cursor
763    /// unchanged. This operation never allocates or panics for any length.
764    pub fn read_exact(&mut self, len: usize) -> Result<&'a [u8], WireError> {
765        let end = self
766            .offset
767            .checked_add(len)
768            .ok_or(WireError::LengthOverflow)?;
769        let bytes = self
770            .bytes
771            .get(self.offset..end)
772            .ok_or(WireError::UnexpectedEof)?;
773        self.offset = end;
774        Ok(bytes)
775    }
776}
777
778/// Append one byte in the same format as [`WireCursor::read_u8`].
779pub fn write_u8(out: &mut Vec<u8>, value: u8) {
780    out.push(value);
781}
782
783/// Append a `u32` as four little-endian bytes, matching [`WireCursor::read_u32`].
784pub fn write_u32(out: &mut Vec<u8>, value: u32) {
785    out.extend_from_slice(&value.to_le_bytes());
786}
787
788/// Append a `u64` as eight little-endian bytes, matching [`WireCursor::read_u64`].
789pub fn write_u64(out: &mut Vec<u8>, value: u64) {
790    out.extend_from_slice(&value.to_le_bytes());
791}
792
793/// Append `len` as a four-byte little-endian `u32`.
794/// Returns `LengthOverflow` without changing `out` if `len` exceeds `u32::MAX`.
795/// The result can be read with [`WireCursor::read_len`].
796pub fn write_len(out: &mut Vec<u8>, len: usize) -> Result<(), WireError> {
797    let len = u32::try_from(len).map_err(|_| WireError::LengthOverflow)?;
798    write_u32(out, len);
799    Ok(())
800}
801
802/// Append a `u32` length prefix followed by the bytes verbatim.
803/// Returns `LengthOverflow` without changing `out` if the slice length exceeds `u32::MAX`.
804/// Read the prefix with [`WireCursor::read_len`] and the payload with [`WireCursor::read_exact`].
805pub fn write_bytes(out: &mut Vec<u8>, bytes: &[u8]) -> Result<(), WireError> {
806    write_len(out, bytes.len())?;
807    out.extend_from_slice(bytes);
808    Ok(())
809}
810
811/// Consume one byte and check it against `expected`.
812/// A mismatch returns `InvalidTag` after consuming the byte; an empty input
813/// returns `UnexpectedEof` without advancing.
814pub(super) fn read_tag(cursor: &mut WireCursor<'_>, expected: u8) -> Result<(), WireError> {
815    match cursor.read_u8()? {
816        tag if tag == expected => Ok(()),
817        _ => Err(WireError::InvalidTag),
818    }
819}
820
821// CRC-32/ISO-HDLC: reflected polynomial, all-ones initialization and final XOR.
822// Detects accidental corruption, including every single-byte change; not a MAC.
823pub(super) fn frame_crc32(bytes: &[u8]) -> u32 {
824    let mut crc = u32::MAX;
825    for &byte in bytes {
826        crc ^= u32::from(byte);
827        for _ in 0..8 {
828            crc = (crc >> 1) ^ (0xedb8_8320 & 0u32.wrapping_sub(crc & 1));
829        }
830    }
831    !crc
832}
833
834impl From<LegacyFrame> for WireError {
835    fn from(found: LegacyFrame) -> Self {
836        Self::LegacyEventLogFrame { found }
837    }
838}
839
840// After a 0x02 tag, a u32 count of length-prefixed records, each opening with
841// the record tag. This is structural only; migration decodes every record. A
842// current frame with a damaged tag fails here (its length complement is read
843// as a record length) and stays InvalidTag rather than being offered migration.
844fn walks_as_tag02_frame(cursor: &WireCursor<'_>) -> bool {
845    let mut rest = WireCursor::new(&cursor.bytes[cursor.offset..]);
846    let mut walk = || -> Result<(), WireError> {
847        for _ in 0..rest.read_len()? {
848            let len = rest.read_len()?;
849            if rest.read_exact(len)?.first() != Some(&TAG_RECORD) {
850                return Err(WireError::InvalidTag);
851            }
852        }
853        Ok(())
854    };
855    walk().is_ok()
856}
857
858// After the 0x03 tag: check the length pair, then the CRC over both length
859// fields and the body, and return the body without decoding any of it.
860pub(super) fn read_checked_body<'a>(cursor: &mut WireCursor<'a>) -> Result<&'a [u8], WireError> {
861    let start = cursor.offset;
862    let len = cursor.read_u32()?;
863    if cursor.read_u32()? != !len {
864        return Err(WireError::IntegrityMismatch);
865    }
866    let body = cursor.read_exact(usize::try_from(len).map_err(|_| WireError::LengthOverflow)?)?;
867    let checksum = frame_crc32(&cursor.bytes[start..cursor.offset]);
868    if cursor.read_u32()? != checksum {
869        return Err(WireError::IntegrityMismatch);
870    }
871    Ok(body)
872}
873
874impl<D: WireEncode + WireSchema> EventLog<D> {
875    /// Encode an inert batch without admitting it into a live log.
876    ///
877    /// This preserves input occurrences, including duplicates and invalid records.
878    /// Receivers must use `records_from_wire_bytes_for` or `from_wire_bytes_for`
879    /// before applying the payload. Encoding does not validate a CRDT domain.
880    pub fn encode_records(
881        replica_count: Option<usize>,
882        records: &[Record<D>],
883        out: &mut Vec<u8>,
884    ) -> Result<(), WireError> {
885        if D::REQUIRES_ARITY && replica_count.is_none() {
886            return Err(WireError::MissingShape);
887        }
888        let mut body = Vec::new();
889        write_u32(&mut body, u32::MAX);
890        write_bytes(&mut body, D::wire_schema().as_ref())?;
891        match replica_count {
892            Some(count) => {
893                write_u8(&mut body, 1);
894                write_u64(
895                    &mut body,
896                    u64::try_from(count).map_err(|_| WireError::LengthOverflow)?,
897                );
898            }
899            None => write_u8(&mut body, 0),
900        }
901        write_len(&mut body, records.len())?;
902        for record in records {
903            write_bytes(&mut body, &record.to_wire_bytes()?)?;
904        }
905        let len = u32::try_from(body.len()).map_err(|_| WireError::LengthOverflow)?;
906        write_u8(out, TAG_EVENT_LOG);
907        let start = out.len();
908        write_u32(out, len);
909        write_u32(out, !len);
910        out.extend_from_slice(&body);
911        let checksum = frame_crc32(&out[start..]);
912        write_u32(out, checksum);
913        Ok(())
914    }
915}
916
917impl<D: WireDecode + WireSchema + PartialEq> EventLog<D> {
918    pub(super) fn decode_with<E: From<WireError>>(
919        cursor: &mut WireCursor<'_>,
920        collection_limits: CollectionLimits,
921        nested_max_records: Option<usize>,
922        mut occurrence: impl FnMut(&Record<D>),
923        mut before_record: impl FnMut(usize) -> Result<(), E>,
924    ) -> Result<Self, E> {
925        match cursor.read_u8()? {
926            TAG_EVENT_LOG => {}
927            TAG_EVENT_LOG_LEGACY if walks_as_tag02_frame(cursor) => {
928                return Err(WireError::from(LegacyFrame::Tag02).into())
929            }
930            _ => return Err(WireError::InvalidTag.into()),
931        }
932        let mut body = WireCursor::new(read_checked_body(cursor)?);
933        if body.read_u32()? != u32::MAX {
934            return Err(WireError::from(LegacyFrame::Tag03Unshaped).into());
935        }
936        let schema_len = body.read_len()?;
937        if body.read_exact(schema_len)? != D::wire_schema().as_ref() {
938            return Err(WireError::DeltaTypeMismatch.into());
939        }
940        let replica_count = match body.read_u8()? {
941            0 if !D::REQUIRES_ARITY => None,
942            0 => return Err(WireError::MissingShape.into()),
943            1 => Some(usize::try_from(body.read_u64()?).map_err(|_| WireError::LengthOverflow)?),
944            _ => return Err(WireError::ArityKindMismatch.into()),
945        };
946        let mut log = EventLog {
947            replica_count,
948            shape_bound: true,
949            ..EventLog::new()
950        };
951        let record_count = body.read_len()?;
952        if let Some(max_records) = nested_max_records {
953            if record_count > max_records {
954                before_record(max_records)?;
955            }
956        }
957        for index in 0..record_count {
958            before_record(index)?;
959            let record_len = body.read_len()?;
960            let record_bytes = body.read_exact(record_len)?;
961            let mut record_cursor = WireCursor::new(record_bytes);
962            let record = Record::<D>::decode_wire_with_limits(
963                &mut record_cursor,
964                collection_limits,
965                nested_max_records,
966            )?;
967            if !record_cursor.is_empty() {
968                return Err(WireError::TrailingBytes.into());
969            }
970            occurrence(&record);
971            match log.identity_admission(&record) {
972                Admission::Collision => return Err(WireError::RecordCollision.into()),
973                Admission::Accepted => log.commit_record(record),
974                Admission::Duplicate => {}
975                Admission::Invalid(_) => unreachable!("identity check does not validate a carrier"),
976            }
977        }
978        if !body.is_empty() {
979            return Err(WireError::TrailingBytes.into());
980        }
981        Ok(log)
982    }
983}