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(×tamp_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}