Skip to main content

peat_lite/protocol/
document.rs

1// Copyright (c) 2025-2026 Defense Unicorns, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Universal Document envelope for cross-transport sync.
5//!
6//! Carries an opaque document body (collection name + id + timestamp +
7//! body bytes) so adding a new collection to the network requires zero
8//! changes to peat-lite, peat-btle, or future LoRa transports — only
9//! the publisher and the consumer agree on the body shape. This
10//! formalizes the universal half of ADR-059 Amendment 4's
11//! universal-vs-application-domain split: typed sensor primitives
12//! (peripheral health, BLE position, canned messages) keep their
13//! domain-specific carriers in peat-btle / peat-protocol; arbitrary
14//! Documents (markers, platforms, tracks, future collections) flow
15//! through this envelope.
16//!
17//! ## Wire layout
18//!
19//! Carried as the payload of a [`MessageType::Document`] frame
20//! (header byte 0x07). The header (16 bytes, [`super::header`]) is
21//! followed immediately by:
22//!
23//! ```text
24//! ┌──────────────┬────────────────┬───────────────┬────────────┬──────────────┬──────┐
25//! │    flags     │ collection_len │  collection   │  doc_id_len│   doc_id     │ ...  │
26//! │   1 byte     │    1 byte      │  N bytes      │  2 bytes LE│  M bytes     │      │
27//! ├──────────────┼────────────────┼───────────────┴────────────┴──────────────┘      │
28//! │ timestamp_ms │   body_len     │             body                                 │
29//! │  8 bytes LE  │  2 bytes LE    │           K bytes (opaque)                       │
30//! └──────────────┴────────────────┴──────────────────────────────────────────────────┘
31//! ```
32//!
33//! - `flags`: bit 0 = deletion-tombstone (body MAY be empty);
34//!   bit 1 = encrypted body; bits 2–7 reserved (must encode 0).
35//! - `collection`: UTF-8, length-prefixed by 1 byte (0–255). Empty
36//!   collection name is invalid and rejected on decode.
37//! - `doc_id`: UTF-8, length-prefixed by 2-byte LE length (0–65535).
38//!   `doc_id_len = 0` means the publisher delegates id assignment to
39//!   the receiving doc store (matching `peat_mesh::Node::publish`'s
40//!   contract for Documents with `id: None`).
41//! - `timestamp_ms`: Unix epoch milliseconds, 8-byte LE i64. Used by
42//!   the receiving CRDT layer for last-writer-wins resolution where
43//!   applicable; lower layers don't interpret it.
44//! - `body`: opaque bytes, length-prefixed by 2-byte LE length
45//!   (0–65535). peat-lite does not interpret the body; consumers
46//!   (peat-mesh, peat-atak-plugin, M5Stack firmware) own the body
47//!   schema. Postcard-encoded `peat_mesh::Document.fields` is the
48//!   conventional choice on the host side, but the wire is agnostic.
49//!
50//! ## Size limits and fragmentation
51//!
52//! Field maxima:
53//!
54//! - `collection`: 255 bytes — comfortably above all current
55//!   collection names (`markers`, `platforms`, `tracks`,
56//!   `company_summaries`, `canned_messages`, …).
57//! - `doc_id`: 65535 bytes — UUIDs (36) and CoT UIDs (~64) fit easily.
58//! - `body`: 65535 bytes — well above peat-lite's
59//!   [`MAX_PAYLOAD_SIZE`] (496 bytes). For LoRa-class transports
60//!   carrying envelopes that fit in a single packet, the spec is
61//!   bounded by the transport, not the codec.
62//!
63//! Total framed envelope on the wire:
64//! `header(16) + 1 + 1 + N + 2 + M + 8 + 2 + K = 30 + N + M + K`
65//!
66//! When the framed envelope exceeds `MAX_PAYLOAD_SIZE`, the
67//! **transport** is responsible for fragmenting (peat-btle's
68//! `chunk_data` / `ChunkReassembler` over GATT, LoRa's scheduler,
69//! etc.). peat-lite envelopes stay opaque to the chunking layer; this
70//! keeps the codec transport-agnostic and lets each radio choose the
71//! fragmentation strategy that fits its MTU + duty-cycle constraints.
72
73use super::error::MessageError;
74
75/// Bit 0: deletion tombstone — the publisher is deleting the document
76/// referenced by `(collection, doc_id)`. Body MUST be empty; the
77/// encoder rejects `tombstone | body.len() > 0` to prevent
78/// publisher-side write-then-delete contract violations.
79pub const DOC_FLAG_TOMBSTONE: u8 = 0x01;
80/// Bit 1: body is encrypted (per-document, key established
81/// out-of-band). **Reserved for a future encryption layer**; today's
82/// encoder rejects the flag entirely (returns
83/// `MessageError::InvalidFlags`) so a conforming sender will never
84/// ship a frame with this bit set.
85///
86/// Note the deliberate asymmetry: the **decoder does not validate
87/// flags**. A frame from a non-conforming or future sender carrying
88/// this bit will decode successfully and surface via
89/// [`DocumentRef::is_encrypted`] for forward-compat inspection — the
90/// decoder is permissive on input from peers that may run a newer
91/// peat-lite version. Consumers MUST treat the body as opaque when
92/// they see the bit set; the encoder is the contract enforcement
93/// point, not the decoder.
94pub const DOC_FLAG_ENCRYPTED: u8 = 0x02;
95/// Mask of currently-defined flag bits. Bits set outside this mask
96/// are reserved for future protocol versions; today's encoder
97/// rejects them with `MessageError::InvalidFlags` so that legacy
98/// frames can't enable not-yet-implemented behaviors. Round-2 of
99/// peat-lite#26 added this reservation contract.
100///
101/// **Adding a new flag**: extend the mask, add the const, AND wire
102/// the encode/decode handling for the new behavior. The
103/// `reserved_flag_bits_are_rejected` test will start failing for the
104/// new bit (since it's no longer reserved), forcing the contributor
105/// to update tests, which is the integration prompt that the
106/// behavior wiring is also incomplete. There is no static-analysis
107/// forcing function, so the test loop is the canary.
108pub const DOC_FLAGS_MASK: u8 = DOC_FLAG_TOMBSTONE | DOC_FLAG_ENCRYPTED;
109
110/// Maximum length of a collection name on the wire (1-byte length
111/// prefix limit).
112pub const MAX_COLLECTION_LEN: usize = u8::MAX as usize;
113/// Maximum length of a doc id on the wire (2-byte length prefix
114/// limit).
115pub const MAX_DOC_ID_LEN: usize = u16::MAX as usize;
116/// Maximum body byte length on the wire (2-byte length prefix
117/// limit). Transport-level fragmentation may carry envelopes larger
118/// than peat-lite's [`MAX_PAYLOAD_SIZE`], up to this hard cap.
119pub const MAX_BODY_LEN: usize = u16::MAX as usize;
120
121/// Decoded view of a Document envelope, borrowing from the input
122/// buffer. Hot-path-friendly for `no_std` consumers — no allocation.
123///
124/// Marked `#[non_exhaustive]` so future protocol amendments can add
125/// fields (e.g. fragmentation sequence number) without breaking
126/// downstream `DocumentRef { ... }` literal constructions.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128#[non_exhaustive]
129pub struct DocumentRef<'a> {
130    /// Flags byte. See [`DOC_FLAG_TOMBSTONE`] / [`DOC_FLAG_ENCRYPTED`].
131    pub flags: u8,
132    /// Collection name as UTF-8 bytes (validated UTF-8 on decode).
133    pub collection: &'a str,
134    /// Document id as UTF-8 bytes; empty slice means "publisher
135    /// delegated id assignment to the receiver."
136    pub doc_id: &'a str,
137    /// Unix epoch milliseconds.
138    pub timestamp_ms: i64,
139    /// Opaque body bytes — peat-lite does not interpret.
140    pub body: &'a [u8],
141}
142
143impl<'a> DocumentRef<'a> {
144    /// Returns true if the deletion-tombstone flag is set.
145    #[inline]
146    pub fn is_tombstone(&self) -> bool {
147        self.flags & DOC_FLAG_TOMBSTONE != 0
148    }
149
150    /// Returns true if the encrypted-body flag is set.
151    #[inline]
152    pub fn is_encrypted(&self) -> bool {
153        self.flags & DOC_FLAG_ENCRYPTED != 0
154    }
155}
156
157/// Encoded length of a Document envelope with the given field sizes,
158/// excluding the 16-byte peat-lite header. Useful for callers
159/// preflight-checking a buffer against [`MAX_PAYLOAD_SIZE`] before
160/// committing to a single-packet send vs. fragmentation.
161///
162/// Returns `None` on `usize` overflow — relevant on 32-bit targets
163/// (ESP32, LoRa MCUs) when callers pass user-controlled lengths. With
164/// today's per-field maxima the worst case sums to ~131 KB which fits
165/// 32-bit `usize` comfortably; the guard exists to keep that
166/// invariant explicit if a future field expansion ever drives the
167/// total higher.
168#[inline]
169pub const fn encoded_len(
170    collection_len: usize,
171    doc_id_len: usize,
172    body_len: usize,
173) -> Option<usize> {
174    // flags(1) + collection_len(1) + collection(N)
175    //        + doc_id_len(2) + doc_id(M)
176    //        + timestamp_ms(8)
177    //        + body_len(2) + body(K)
178    let fixed = 1 + 1 + 2 + 8 + 2;
179    let Some(t) = collection_len.checked_add(doc_id_len) else {
180        return None;
181    };
182    let Some(t) = t.checked_add(body_len) else {
183        return None;
184    };
185    let Some(t) = t.checked_add(fixed) else {
186        return None;
187    };
188    Some(t)
189}
190
191/// Encode a Document envelope into `buf`. Returns the number of bytes
192/// written, or an error if any field exceeds its width, the buffer is
193/// too small, or the flags / tombstone-vs-body invariants are
194/// violated.
195///
196/// `buf` is the payload region — callers should already have written
197/// the 16-byte peat-lite header (with [`MessageType::Document`]) into
198/// the preceding bytes via [`super::header::encode_header`].
199///
200/// ## Validity contracts enforced
201///
202/// - `collection`: non-empty, ≤ [`MAX_COLLECTION_LEN`] bytes, must
203///   not contain NUL bytes (NUL would create routing-key ambiguity
204///   downstream where consumers may treat `"markers\0"` and
205///   `"markers"` as different OR the same depending on path).
206/// - `doc_id`: ≤ [`MAX_DOC_ID_LEN`] bytes; empty signals
207///   publisher-delegates-id.
208/// - `body`: ≤ [`MAX_BODY_LEN`] bytes; **MUST be empty when
209///   `flags & DOC_FLAG_TOMBSTONE != 0`**. The encoder rejects
210///   tombstone-with-body to prevent publisher contract violations
211///   that downstream consumers might race write-then-delete against.
212/// - `flags`: only bits in [`DOC_FLAGS_MASK`] may be set. Reserved
213///   bits 2–7 plus the encrypted bit (today not implemented) all
214///   produce [`MessageError::InvalidFlags`].
215pub fn encode(
216    flags: u8,
217    collection: &str,
218    doc_id: &str,
219    timestamp_ms: i64,
220    body: &[u8],
221    buf: &mut [u8],
222) -> Result<usize, MessageError> {
223    // Flag validity: only bits in DOC_FLAGS_MASK are defined today,
224    // AND the encrypted bit is reserved for a future encryption layer
225    // we haven't wired through. Reject either condition so legacy
226    // encoders can't ship frames newer consumers don't know how to
227    // process. Tombstone (bit 0) is the only flag actually emitted.
228    if flags & !DOC_FLAGS_MASK != 0 || flags & DOC_FLAG_ENCRYPTED != 0 {
229        return Err(MessageError::InvalidFlags);
230    }
231
232    let coll_bytes = collection.as_bytes();
233    let id_bytes = doc_id.as_bytes();
234
235    if coll_bytes.is_empty() {
236        return Err(MessageError::EmptyCollection);
237    }
238    // NUL bytes in collection name OR doc id create routing-key
239    // ambiguity: some string-comparison paths treat the embedded NUL
240    // as a terminator, others as a literal byte. Both fields route
241    // into the doc store identically, so the rejection has to be
242    // symmetric. (Round-3 review caught the asymmetry: collection was
243    // rejected, doc_id wasn't.)
244    if coll_bytes.contains(&0) || id_bytes.contains(&0) {
245        return Err(MessageError::InvalidUtf8);
246    }
247
248    // Tombstone-vs-body invariant: a tombstone is a deletion sentinel,
249    // not a "delete then create" combo. Carrying a body alongside the
250    // flag invites publisher-side contract violations (publisher
251    // forgot to clear the body buffer; adversarial sender combining
252    // ops). Reject at encode so downstream consumers can trust the
253    // invariant unconditionally.
254    //
255    // Checked before the size-cap below so a tombstone with a 70 KB
256    // body surfaces as `TombstoneWithBody` (semantically primary)
257    // rather than `FieldTooLarge` (size-only) — round-3 review
258    // flagged the inverted priority.
259    if flags & DOC_FLAG_TOMBSTONE != 0 && !body.is_empty() {
260        return Err(MessageError::TombstoneWithBody);
261    }
262
263    if coll_bytes.len() > MAX_COLLECTION_LEN
264        || id_bytes.len() > MAX_DOC_ID_LEN
265        || body.len() > MAX_BODY_LEN
266    {
267        return Err(MessageError::FieldTooLarge);
268    }
269
270    let needed = encoded_len(coll_bytes.len(), id_bytes.len(), body.len())
271        .ok_or(MessageError::FieldTooLarge)?;
272    if buf.len() < needed {
273        return Err(MessageError::BufferTooSmall);
274    }
275
276    let mut o = 0;
277    buf[o] = flags;
278    o += 1;
279    buf[o] = coll_bytes.len() as u8;
280    o += 1;
281    buf[o..o + coll_bytes.len()].copy_from_slice(coll_bytes);
282    o += coll_bytes.len();
283    buf[o..o + 2].copy_from_slice(&(id_bytes.len() as u16).to_le_bytes());
284    o += 2;
285    buf[o..o + id_bytes.len()].copy_from_slice(id_bytes);
286    o += id_bytes.len();
287    buf[o..o + 8].copy_from_slice(&timestamp_ms.to_le_bytes());
288    o += 8;
289    buf[o..o + 2].copy_from_slice(&(body.len() as u16).to_le_bytes());
290    o += 2;
291    buf[o..o + body.len()].copy_from_slice(body);
292    o += body.len();
293
294    // Runtime assertion (not debug-only): a logic bug here would
295    // produce a wrong wire length silently in release builds, which
296    // is exactly the catastrophic divergence the QA round-2 review
297    // flagged. Plain `assert_eq!` (no format args) keeps the
298    // `no_std` ESP32 / LoRa MCU `.text` footprint minimal — the
299    // condition is the diagnostic.
300    assert_eq!(o, needed);
301    Ok(o)
302}
303
304/// Decode a Document envelope from `buf` (the payload region after
305/// the 16-byte peat-lite header). Returns a borrowing view; bumps any
306/// length-related parse error to a single error variant rather than
307/// returning partial data, matching how the rest of the protocol
308/// module surfaces malformed wire input.
309pub fn decode(buf: &[u8]) -> Result<DocumentRef<'_>, MessageError> {
310    let mut o = 0;
311    if buf.len() < 1 + 1 {
312        return Err(MessageError::TooShort);
313    }
314    let flags = buf[o];
315    o += 1;
316    let coll_len = buf[o] as usize;
317    o += 1;
318    if buf.len() < o + coll_len {
319        return Err(MessageError::TruncatedField);
320    }
321    let collection =
322        core::str::from_utf8(&buf[o..o + coll_len]).map_err(|_| MessageError::InvalidUtf8)?;
323    if collection.is_empty() {
324        // Mirror the encode-time rejection. Distinct error variant
325        // from `TruncatedField` (which is for buffer-shorter-than-
326        // declared) so callers get an unambiguous diagnosis.
327        return Err(MessageError::EmptyCollection);
328    }
329    if collection.as_bytes().contains(&0) {
330        // NUL bytes in routing keys create downstream ambiguity
331        // (string-comparison paths may treat NUL as terminator vs
332        // literal). Reject for parity with the encoder check.
333        return Err(MessageError::InvalidUtf8);
334    }
335    o += coll_len;
336
337    if buf.len() < o + 2 {
338        return Err(MessageError::TruncatedField);
339    }
340    let id_len = u16::from_le_bytes([buf[o], buf[o + 1]]) as usize;
341    o += 2;
342    if buf.len() < o + id_len {
343        return Err(MessageError::TruncatedField);
344    }
345    let doc_id =
346        core::str::from_utf8(&buf[o..o + id_len]).map_err(|_| MessageError::InvalidUtf8)?;
347    if doc_id.as_bytes().contains(&0) {
348        // Mirror the collection NUL rejection — doc_id is also a
349        // routing key in the doc store. Round-3 review flagged the
350        // asymmetry.
351        return Err(MessageError::InvalidUtf8);
352    }
353    o += id_len;
354
355    if buf.len() < o + 8 {
356        return Err(MessageError::TruncatedField);
357    }
358    let mut ts = [0u8; 8];
359    ts.copy_from_slice(&buf[o..o + 8]);
360    let timestamp_ms = i64::from_le_bytes(ts);
361    o += 8;
362
363    if buf.len() < o + 2 {
364        return Err(MessageError::TruncatedField);
365    }
366    let body_len = u16::from_le_bytes([buf[o], buf[o + 1]]) as usize;
367    o += 2;
368    if buf.len() < o + body_len {
369        return Err(MessageError::TruncatedField);
370    }
371    let body = &buf[o..o + body_len];
372
373    Ok(DocumentRef {
374        flags,
375        collection,
376        doc_id,
377        timestamp_ms,
378        body,
379    })
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    /// Round-trip a Document envelope with non-empty collection +
387    /// doc_id + body and verify every field matches.
388    #[test]
389    fn roundtrip_full() {
390        let mut buf = [0u8; 512];
391        let body = b"opaque-body-bytes";
392        let n = encode(
393            0,
394            "markers",
395            "uuid-abc-123",
396            1_700_000_000_000,
397            body,
398            &mut buf,
399        )
400        .expect("encode");
401        let view = decode(&buf[..n]).expect("decode");
402        assert_eq!(view.flags, 0);
403        assert_eq!(view.collection, "markers");
404        assert_eq!(view.doc_id, "uuid-abc-123");
405        assert_eq!(view.timestamp_ms, 1_700_000_000_000);
406        assert_eq!(view.body, body);
407        assert!(!view.is_tombstone());
408        assert!(!view.is_encrypted());
409    }
410
411    /// Empty doc_id signals "backend-assigned id" per spec — must
412    /// round-trip cleanly, not be conflated with malformed input.
413    #[test]
414    fn roundtrip_empty_doc_id() {
415        let mut buf = [0u8; 256];
416        let n = encode(0, "markers", "", 0, b"x", &mut buf).expect("encode");
417        let view = decode(&buf[..n]).expect("decode");
418        assert_eq!(view.doc_id, "");
419        assert_eq!(view.body, b"x");
420    }
421
422    /// Tombstone flag is preserved + helper accessor returns true.
423    /// Body MAY be empty for tombstones (publisher signals deletion
424    /// without re-shipping content).
425    #[test]
426    fn roundtrip_tombstone_empty_body() {
427        let mut buf = [0u8; 64];
428        let n = encode(
429            DOC_FLAG_TOMBSTONE,
430            "platforms",
431            "ANDROID-x",
432            42,
433            &[],
434            &mut buf,
435        )
436        .expect("encode");
437        let view = decode(&buf[..n]).expect("decode");
438        assert!(view.is_tombstone());
439        assert!(!view.is_encrypted());
440        assert_eq!(view.body.len(), 0);
441    }
442
443    /// Empty collection is structurally invalid — receivers can't
444    /// route the document. Encoder + decoder both surface
445    /// `EmptyCollection` (round-2 of peat-lite#26 added the variant
446    /// to fix the previous asymmetric `FieldTooLarge` /
447    /// `TruncatedField` reporting).
448    #[test]
449    fn empty_collection_is_rejected_with_distinct_variant() {
450        let mut buf = [0u8; 64];
451        assert_eq!(
452            encode(0, "", "id", 0, b"x", &mut buf),
453            Err(MessageError::EmptyCollection),
454            "encode-side empty-collection must surface EmptyCollection"
455        );
456
457        // Hand-craft a wire payload with coll_len=0 and verify decode
458        // also surfaces EmptyCollection — symmetric with encode.
459        let mut wire = [0u8; 16];
460        wire[0] = 0; // flags
461        wire[1] = 0; // coll_len = 0
462                     // doc_id_len = 0 follows at [2..4]; rest unused
463        assert_eq!(
464            decode(&wire[..4]),
465            Err(MessageError::EmptyCollection),
466            "decode-side empty-collection must surface EmptyCollection"
467        );
468    }
469
470    /// Truncated wire input (each field one byte short) surfaces as
471    /// `TruncatedField` rather than panicking on slice bounds. Locks
472    /// the malformed-input contract that LoRa-class consumers depend
473    /// on.
474    #[test]
475    fn truncated_input_returns_error_not_panic() {
476        let mut buf = [0u8; 256];
477        let n = encode(0, "markers", "id", 1, b"body", &mut buf).expect("encode");
478
479        // Truncate at every byte boundary inside the envelope; every
480        // shorter slice must Err with TruncatedField (or TooShort for
481        // the very smallest cases).
482        for trunc in 0..n {
483            let res = decode(&buf[..trunc]);
484            assert!(
485                matches!(
486                    res,
487                    Err(MessageError::TooShort) | Err(MessageError::TruncatedField),
488                ),
489                "trunc {} should be TooShort/TruncatedField, got {:?}",
490                trunc,
491                res
492            );
493        }
494    }
495
496    /// Non-UTF-8 bytes in the collection field surface as
497    /// `InvalidUtf8`. The wire contract declares UTF-8; rejecting
498    /// non-conforming input keeps the consumer side from having to
499    /// guard against arbitrary byte slices in routing keys.
500    #[test]
501    fn non_utf8_collection_rejected() {
502        // Hand-build wire bytes: flags=0, coll_len=3, coll=[0xFF, 0xFE,
503        // 0xFD] (invalid UTF-8 continuation), then minimum trailing.
504        let mut wire = [0u8; 32];
505        wire[0] = 0;
506        wire[1] = 3;
507        wire[2] = 0xFF;
508        wire[3] = 0xFE;
509        wire[4] = 0xFD;
510        wire[5] = 0; // doc_id_len lo
511        wire[6] = 0; // doc_id_len hi
512                     // timestamp 7..15
513                     // body_len 15..17
514        assert_eq!(decode(&wire[..17]), Err(MessageError::InvalidUtf8));
515    }
516
517    /// `encoded_len` matches what `encode` actually writes — the
518    /// const helper is the size oracle callers use to preflight
519    /// against [`MAX_PAYLOAD_SIZE`]; drift would be a silent
520    /// fragmentation-decision bug.
521    #[test]
522    fn encoded_len_matches_encode() {
523        let mut buf = [0u8; 1024];
524        let body = vec![0xAB; 300];
525        let n = encode(0, "tracks", "id-1", 7, &body, &mut buf).expect("encode");
526        assert_eq!(
527            n,
528            encoded_len("tracks".len(), "id-1".len(), body.len()).expect("no overflow"),
529            "encoded_len drift",
530        );
531    }
532
533    /// `encoded_len` guards against `usize` overflow on 32-bit
534    /// targets. Three checked_add calls — exercise overflow at each
535    /// (col+id, that_sum+body, that_sum+fixed). The third path
536    /// (`+ fixed`) is the "almost fit" case; round-3 review noted it
537    /// wasn't covered.
538    #[test]
539    fn encoded_len_returns_none_on_overflow() {
540        // First checked_add overflows: collection_len + doc_id_len
541        assert_eq!(encoded_len(usize::MAX, 1, 0), None);
542        // Second checked_add overflows: + body_len
543        assert_eq!(encoded_len(0, usize::MAX, 1), None);
544        // Third checked_add overflows: + fixed (14). Only triggers
545        // when the cumulative sum is in the last 14 of usize range.
546        assert_eq!(encoded_len(usize::MAX - 13, 0, 0), None);
547        assert_eq!(encoded_len(0, 0, usize::MAX - 13), None);
548        // Boundary: exactly fits (cumulative + fixed == usize::MAX).
549        assert_eq!(
550            encoded_len(usize::MAX - 14, 0, 0),
551            Some(usize::MAX - 14 + 14)
552        );
553    }
554
555    /// Field-size limits enforced at encode time. Collection > 255
556    /// and body > 65535 must fail-loud rather than silently wrap.
557    #[test]
558    fn oversize_fields_are_rejected() {
559        let mut buf = [0u8; 512];
560
561        // Collection > 255 bytes.
562        let big_coll = "x".repeat(MAX_COLLECTION_LEN + 1);
563        assert_eq!(
564            encode(0, &big_coll, "id", 0, b"x", &mut buf),
565            Err(MessageError::FieldTooLarge),
566        );
567
568        // Body > 65535 bytes — caller would need a 64KB buffer to
569        // even attempt this; verify the size check fires before any
570        // copy.
571        let body_too_large = vec![0u8; MAX_BODY_LEN + 1];
572        let needed = encoded_len("c".len(), "id".len(), body_too_large.len()).expect("no overflow");
573        let mut huge = vec![0u8; needed];
574        assert_eq!(
575            encode(0, "c", "id", 0, &body_too_large, &mut huge),
576            Err(MessageError::FieldTooLarge),
577        );
578    }
579
580    // -------------------------------------------------------------
581    // Round-2 review additions: adversarial-input invariants
582    // -------------------------------------------------------------
583
584    /// `DOC_FLAG_ENCRYPTED` is reserved — encoder rejects any frame
585    /// with bit 1 set. Forward-compat: prevents a non-conforming
586    /// sender from shipping frames downstream consumers can't
587    /// decrypt while the encryption layer is still being designed.
588    #[test]
589    fn encrypted_flag_is_rejected_today() {
590        let mut buf = [0u8; 64];
591        assert_eq!(
592            encode(DOC_FLAG_ENCRYPTED, "markers", "id", 0, b"x", &mut buf),
593            Err(MessageError::InvalidFlags),
594        );
595    }
596
597    /// Reserved flag bits 2-7 are rejected. Adding a new flag in a
598    /// future protocol version is then opt-in (set the bit + bump
599    /// peat-lite version) rather than implicit; legacy encoders
600    /// can't accidentally enable behaviors they don't implement.
601    #[test]
602    fn reserved_flag_bits_are_rejected() {
603        let mut buf = [0u8; 64];
604        for bit in 2..=7 {
605            let flags = 1u8 << bit;
606            assert_eq!(
607                encode(flags, "markers", "id", 0, b"x", &mut buf),
608                Err(MessageError::InvalidFlags),
609                "bit {} should be rejected as reserved",
610                bit
611            );
612        }
613    }
614
615    /// Tombstone with a non-empty body is a publisher contract
616    /// violation — encoder rejects so downstream consumers don't
617    /// have to handle write-then-delete races.
618    #[test]
619    fn tombstone_with_non_empty_body_is_rejected() {
620        let mut buf = [0u8; 64];
621        assert_eq!(
622            encode(
623                DOC_FLAG_TOMBSTONE,
624                "tracks",
625                "id",
626                0,
627                b"unwanted body",
628                &mut buf
629            ),
630            Err(MessageError::TombstoneWithBody),
631        );
632    }
633
634    /// NUL bytes in a collection name create routing-key ambiguity
635    /// (string-comparison paths may treat embedded NUL as terminator
636    /// vs literal byte). Encoder + decoder both reject for parity.
637    #[test]
638    fn nul_bytes_in_collection_are_rejected() {
639        let mut buf = [0u8; 64];
640        assert_eq!(
641            encode(0, "mark\0ers", "id", 0, b"x", &mut buf),
642            Err(MessageError::InvalidUtf8),
643        );
644
645        // Hand-craft a wire payload with a NUL-bearing collection
646        // name and verify decode also rejects.
647        let mut wire = [0u8; 32];
648        wire[0] = 0; // flags
649        wire[1] = 8; // coll_len = 8
650        wire[2..10].copy_from_slice(b"mark\0ers");
651        wire[10] = 0; // doc_id_len lo
652        wire[11] = 0; // doc_id_len hi
653                      // timestamp 12..20
654                      // body_len 20..22
655        assert_eq!(
656            decode(&wire[..22]),
657            Err(MessageError::InvalidUtf8),
658            "decode must reject NUL-bearing collection for symmetry with encode"
659        );
660    }
661
662    /// **Round-3**: doc_id is also a routing key (the doc store
663    /// indexes on `(collection, id)`); embedded NUL bytes get the
664    /// same treatment as in collection. Round-2 only enforced this
665    /// for `collection`; round-3 closed the asymmetry.
666    #[test]
667    fn nul_bytes_in_doc_id_are_rejected() {
668        let mut buf = [0u8; 64];
669        assert_eq!(
670            encode(0, "markers", "id\0bad", 0, b"x", &mut buf),
671            Err(MessageError::InvalidUtf8),
672        );
673
674        // Hand-craft a wire payload with a NUL-bearing doc_id and
675        // verify decode also rejects.
676        let mut wire = [0u8; 64];
677        wire[0] = 0; // flags
678        wire[1] = 7; // coll_len = 7
679        wire[2..9].copy_from_slice(b"markers");
680        wire[9..11].copy_from_slice(&6u16.to_le_bytes()); // doc_id_len = 6
681        wire[11..17].copy_from_slice(b"id\0bad");
682        // timestamp 17..25 (zeros)
683        // body_len 25..27 (zeros)
684        assert_eq!(
685            decode(&wire[..27]),
686            Err(MessageError::InvalidUtf8),
687            "decode must reject NUL-bearing doc_id for symmetry with encode"
688        );
689    }
690
691    /// **Round-3 P2-3**: tombstone-with-body invariant takes priority
692    /// over the size-cap check. A tombstone with a 70 KB body should
693    /// surface as `TombstoneWithBody` (semantically primary), not
694    /// `FieldTooLarge` (size-only) — round-2 had the priority
695    /// inverted, which would have misdirected callers debugging
696    /// upstream contract violations.
697    #[test]
698    fn tombstone_with_oversize_body_surfaces_tombstone_error() {
699        let body_too_large = vec![0u8; MAX_BODY_LEN + 1];
700        let needed = encoded_len("c".len(), "id".len(), body_too_large.len()).expect("no overflow");
701        let mut buf = vec![0u8; needed];
702        assert_eq!(
703            encode(DOC_FLAG_TOMBSTONE, "c", "id", 0, &body_too_large, &mut buf),
704            Err(MessageError::TombstoneWithBody),
705            "tombstone-vs-body invariant must win against size-cap"
706        );
707    }
708
709    /// **Round-3 P3-1**: `tombstone | encrypted` (0x03) — the
710    /// encrypted-bit reject clause is independent of the tombstone
711    /// bit, so combining them must still fail. Locks the
712    /// short-circuit behavior under test.
713    #[test]
714    fn tombstone_combined_with_encrypted_is_rejected() {
715        let mut buf = [0u8; 64];
716        assert_eq!(
717            encode(
718                DOC_FLAG_TOMBSTONE | DOC_FLAG_ENCRYPTED,
719                "markers",
720                "id",
721                0,
722                &[],
723                &mut buf
724            ),
725            Err(MessageError::InvalidFlags),
726            "tombstone+encrypted must surface InvalidFlags (encrypted clause wins)"
727        );
728    }
729
730    /// **Round-3 P2-1**: fuzz round-trip exercises the
731    /// `MAX_COLLECTION_LEN` boundary. Round-2's fuzz used
732    /// `coll_choices` capped at 17 bytes, so the 1-byte length-prefix
733    /// boundary at 255 was unreached. This dedicated test fills the
734    /// gap deterministically.
735    #[test]
736    fn roundtrip_at_collection_max_length_boundary() {
737        let max_coll = "x".repeat(MAX_COLLECTION_LEN); // 255 bytes
738        let mut buf = vec![0u8; 1024];
739        let n = encode(0, &max_coll, "id", 7, b"body", &mut buf).expect("encode at boundary");
740        let view = decode(&buf[..n]).expect("decode at boundary");
741        assert_eq!(view.collection.len(), MAX_COLLECTION_LEN);
742        assert_eq!(view.collection, max_coll);
743        assert_eq!(view.body, b"body");
744
745        // One byte past the boundary fails as expected.
746        let too_long = "x".repeat(MAX_COLLECTION_LEN + 1);
747        let mut buf2 = vec![0u8; 2048];
748        assert_eq!(
749            encode(0, &too_long, "id", 0, b"x", &mut buf2),
750            Err(MessageError::FieldTooLarge),
751        );
752    }
753
754    /// Randomised round-trip: encode-decode-assert-eq across a wide
755    /// input matrix. Catches the entire class of issues that fixed-
756    /// fixture tests miss — boundary values, character ranges,
757    /// length-prefix arithmetic. Deterministic seed keeps CI stable.
758    #[test]
759    fn fuzzy_roundtrip_random_inputs() {
760        // Tiny LCG — no external dep, deterministic, good enough for
761        // input matrix coverage in a unit test. Seed chosen so all
762        // four field-length axes (small/medium collection, small/large
763        // doc_id, varied body) get exercised.
764        let mut state: u32 = 0xCAFEBABE;
765        let mut next = || {
766            state = state.wrapping_mul(1664525).wrapping_add(1013904223);
767            state
768        };
769
770        for _ in 0..200 {
771            let coll_choices = ["m", "markers", "platforms", "alerts", "company_summaries"];
772            let coll = coll_choices[(next() as usize) % coll_choices.len()];
773
774            // doc_id: 0..256 ASCII bytes (UTF-8-clean by construction)
775            let id_len = (next() as usize) % 257;
776            let mut id = String::with_capacity(id_len);
777            for _ in 0..id_len {
778                id.push(char::from(0x21 + ((next() & 0x3F) as u8))); // '!'..'`'
779            }
780
781            let body_len = (next() as usize) % 1024;
782            let mut body = vec![0u8; body_len];
783            for byte in body.iter_mut() {
784                *byte = next() as u8;
785            }
786
787            let ts = next() as i64;
788            let needed = encoded_len(coll.len(), id.len(), body.len()).expect("no overflow");
789            let mut buf = vec![0u8; needed];
790            let n = encode(0, coll, &id, ts, &body, &mut buf).expect("encode random input");
791            assert_eq!(n, needed);
792
793            let view = decode(&buf).expect("decode random input");
794            assert_eq!(view.collection, coll);
795            assert_eq!(view.doc_id, id);
796            assert_eq!(view.timestamp_ms, ts);
797            assert_eq!(view.body, body.as_slice());
798        }
799    }
800}