Skip to main content

offline_protocol_core/
wire.rs

1//! Compact binary wire codec for [`Message`](crate::Message) (wire format **v1**).
2//!
3//! This is an **additive** second encoding alongside the canonical JSON
4//! representation — it does not replace it. JSON remains the permanent
5//! interoperability floor: every receiver still accepts JSON, and a binary frame
6//! is only ever produced for a peer known to support it. The two encodings are
7//! distinguished by the first byte alone ([`WIRE_V1_MAGIC`] vs. `{` = `0x7B`), so
8//! no negotiation is required to *decode*.
9//!
10//! # Why a frozen DTO
11//!
12//! [`Message`](crate::Message) carries `#[serde(default)]` and
13//! `#[serde(skip_serializing_if)]` attributes plus validation-on-deserialize
14//! impls that a non-self-describing binary format (postcard) cannot honor
15//! field-for-field. Rather than serialize `Message` directly, this module defines
16//! `WireMessageV1`, a flat DTO with a fixed field order, and converts to/from
17//! `Message` through the validating constructors (`UserId::new`,
18//! `LamportClock::from_value`, …). That keeps the security checks the JSON path
19//! enforces (identifier caps, Lamport clamp) intact on the binary path.
20//!
21//! # Evolution contract
22//!
23//! postcard is positional: reordering, removing, retyping, or inserting a field
24//! silently corrupts decoding on peers running the previous layout. Therefore:
25//!
26//! * **Never** change `WireMessageV1`'s existing fields.
27//! * Additive, backward-compatible data goes into `WireMessageV1::ext`, a
28//!   trailing `(tag, bytes)` list that old decoders read and ignore.
29//! * A change that cannot be expressed as an `ext` entry requires a new magic
30//!   byte (`0xF6` = v2) and out-of-band version negotiation.
31//!
32//! The numeric enum mappings (`priority_to_u8`, `content_type_to_u8`) are
33//! likewise a frozen wire contract.
34//!
35//! (`WireMessageV1` and the two mapping helpers are deliberately private — the
36//! DTO is an encoding detail, not API surface — so they are named here in code
37//! spans rather than as intra-doc links, which rustdoc rejects for private
38//! items in public documentation.)
39
40use alloc::format;
41use alloc::string::{String, ToString};
42use alloc::vec::Vec;
43
44use crate::message::{ContentType, MessagePriority};
45use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
46use serde::{Deserialize, Serialize};
47
48/// Magic/version byte prefixed to every wire-format-v1 binary frame.
49///
50/// Drawn from `0xF5..=0xFF`: these are invalid UTF-8 leading bytes and cannot
51/// begin a JSON document, so a decoder tells a binary frame from the legacy JSON
52/// encoding (which always starts with `{` = `0x7B`) by inspecting one byte, with
53/// no negotiation. Future breaking revisions take the next value (`0xF6` = v2, …),
54/// leaving room for eleven wire versions before the range is exhausted.
55pub const WIRE_V1_MAGIC: u8 = 0xF5;
56
57/// Logical wire-format version advertised during capability negotiation,
58/// corresponding to [`WIRE_V1_MAGIC`]. Peers exchange the set of versions they
59/// can decode in their signed key package; a peer that lists this value can
60/// decode `WIRE_V1_MAGIC` frames, so it is safe to send it binary.
61pub const WIRE_VERSION_V1: u8 = 1;
62
63/// `ext` TLV tag: the trailing base64 run of `content`, carried as raw bytes.
64///
65/// Emitted by `Message::to_wire_v1_bytes` when `content` ends in a long
66/// canonical-base64 tail (an MLS envelope, for example): the wire `content`
67/// keeps only the head and the decoded tail rides here, saving the 4/3 base64
68/// inflation. `Message::from_wire_v1_bytes` re-encodes the bytes and appends
69/// them to `content`, reconstructing the original string byte-for-byte — the
70/// split is only taken when [`split_b64_tail`] verifies that property at
71/// encode time.
72///
73/// # Tag registry (wire v1)
74///
75/// | tag | meaning                                      |
76/// |-----|----------------------------------------------|
77/// | 1   | base64 tail of `content`, decoded            |
78/// | 2   | `reply_context` as JSON ([`EXT_TAG_REPLY_CONTEXT`]) |
79///
80/// Tag 1 ships in wire v1's *first* release, so advertising [`WIRE_VERSION_V1`]
81/// implies understanding it. Note the constraint that imposes on future tags:
82/// a decoder that ignores tag 1 would reconstruct a truncated `content`, which
83/// is only safe because no v1 decoder without tag-1 support ever shipped. A
84/// future tag whose absence changes meaning (rather than merely costing
85/// efficiency or optional context) cannot piggyback on v1 — it needs a new
86/// wire version.
87pub(crate) const EXT_TAG_B64_TAIL: u16 = 1;
88
89/// `ext` TLV tag: [`ReplyContext`](crate::ReplyContext) serialized as JSON.
90///
91/// Rides opaquely (like `media_metadata_json`) so the struct keeps evolving
92/// through its own serde attributes. Unlike tag 1, this tag may piggyback on
93/// v1 even though not every v1 decoder understands it: a decoder that skips
94/// it delivers the message without its reply preview — exactly the
95/// degradation a legacy JSON receiver applies by ignoring the unknown
96/// `reply_context` field. Meaning is preserved; only optional display
97/// context is lost.
98///
99/// Decoders honor only the first tag-2 entry and reject a frame whose
100/// payload is not valid `ReplyContext` JSON, matching the JSON path where a
101/// malformed `reply_context` rejects the whole message.
102pub(crate) const EXT_TAG_REPLY_CONTEXT: u16 = 2;
103
104/// Minimum length (in base64 characters, including padding) of a trailing
105/// base64 run before [`split_b64_tail`] moves it to the ext TLV. Short tails
106/// save a handful of bytes at the cost of a TLV entry and an alphabet scan on
107/// every hop; 64 characters (48 raw bytes) is where the trade clearly wins.
108const B64_TAIL_MIN_LEN: usize = 64;
109
110/// Splits `content` into `(head, raw)` such that
111/// `head + BASE64.encode(raw) == content`, byte-for-byte.
112///
113/// Returns `Some` only when `content` ends in a run of at least
114/// [`B64_TAIL_MIN_LEN`] standard-alphabet base64 characters (4-aligned, with
115/// canonical `=` padding only at the very end) that strict-decodes **and**
116/// re-encodes to exactly the original tail. That re-encode comparison makes
117/// the split correct by construction for arbitrary input: non-canonical
118/// padding, foreign alphabets, or lookalike text simply fail the comparison
119/// and the content rides as plain text. A run longer than needed for
120/// 4-alignment sheds its leading remainder into the head, which preserves the
121/// reconstruction property.
122pub(crate) fn split_b64_tail(content: &str) -> Option<(&str, Vec<u8>)> {
123    let bytes = content.as_bytes();
124    if bytes.len() < B64_TAIL_MIN_LEN {
125        return None;
126    }
127    // Canonical padding (at most two '='), only at the very end.
128    let mut run_end = bytes.len();
129    let mut pad = 0;
130    while pad < 2 && run_end > 0 && bytes[run_end - 1] == b'=' {
131        run_end -= 1;
132        pad += 1;
133    }
134    let is_b64 = |b: u8| b.is_ascii_alphanumeric() || b == b'+' || b == b'/';
135    let mut start = run_end;
136    while start > 0 && is_b64(bytes[start - 1]) {
137        start -= 1;
138    }
139    // Base64 decodes in 4-character quanta; align by shedding the leading
140    // remainder of the run into the head. All run characters are ASCII, so
141    // both `start` and the shifted index stay on char boundaries.
142    start += (bytes.len() - start) % 4;
143    let tail = &content[start..];
144    if tail.len() < B64_TAIL_MIN_LEN {
145        return None;
146    }
147    let raw = BASE64.decode(tail).ok()?;
148    if BASE64.encode(&raw) != tail {
149        return None;
150    }
151    Some((&content[..start], raw))
152}
153
154/// Re-encodes a tag-1 ext value for appending to `content`; the exact inverse
155/// of [`split_b64_tail`]'s decode.
156pub(crate) fn encode_b64_tail(raw: &[u8]) -> String {
157    BASE64.encode(raw)
158}
159
160/// Flat, fixed-layout DTO mirroring [`Message`](crate::Message) for wire v1.
161///
162/// See the [module docs](self) for the field-stability contract. Fields are
163/// `pub(crate)` so the `Message` conversion in `message.rs` can build and read
164/// them; the type itself never leaves the crate.
165#[derive(Serialize, Deserialize)]
166pub(crate) struct WireMessageV1 {
167    pub(crate) id: [u8; 16],
168    pub(crate) sender: String,
169    pub(crate) recipient: String,
170    pub(crate) app_id: String,
171    pub(crate) priority: u8,
172    pub(crate) ttl: u8,
173    pub(crate) hop_count: u8,
174    pub(crate) timestamp: i64,
175    pub(crate) lamport_clock: u64,
176    pub(crate) content_type: u8,
177    pub(crate) content: String,
178    pub(crate) binary_content: Option<Vec<u8>>,
179    /// [`MediaMetadata`](crate::MediaMetadata) serialized as JSON. Rare and
180    /// structurally rich, so it rides as an opaque blob to keep the frozen
181    /// surface small and let it keep evolving via its own serde attributes.
182    pub(crate) media_metadata_json: Option<Vec<u8>>,
183    /// Sorted for deterministic output (a `HashMap` iterates nondeterministically).
184    pub(crate) metadata: Vec<(String, String)>,
185    pub(crate) requires_ack: bool,
186    pub(crate) reply_to_msg: Option<[u8; 16]>,
187    /// [`ForwardInfo`](crate::ForwardInfo) serialized as JSON; see
188    /// `media_metadata_json`.
189    pub(crate) forwarded_from_json: Option<Vec<u8>>,
190    /// Forward-compatible extension slots — `(tag, bytes)`. Empty in v1.
191    pub(crate) ext: Vec<(u16, Vec<u8>)>,
192}
193
194/// Frozen wire mapping for [`MessagePriority`]. Must never change.
195pub(crate) fn priority_to_u8(p: MessagePriority) -> u8 {
196    match p {
197        MessagePriority::Low => 0,
198        MessagePriority::Medium => 1,
199        MessagePriority::High => 2,
200        MessagePriority::Critical => 3,
201    }
202}
203
204/// Inverse of [`priority_to_u8`]. Unknown values fall back to the default
205/// (`Medium`) so a frame from a future peer is delivered, not dropped.
206pub(crate) fn priority_from_u8(v: u8) -> MessagePriority {
207    match v {
208        0 => MessagePriority::Low,
209        1 => MessagePriority::Medium,
210        2 => MessagePriority::High,
211        3 => MessagePriority::Critical,
212        _ => MessagePriority::Medium,
213    }
214}
215
216/// Frozen wire mapping for [`ContentType`]. Must never change; append new
217/// variants at the end.
218pub(crate) fn content_type_to_u8(c: ContentType) -> u8 {
219    match c {
220        ContentType::Text => 0,
221        ContentType::Image => 1,
222        ContentType::Video => 2,
223        ContentType::Audio => 3,
224        ContentType::VoiceNote => 4,
225        ContentType::VideoNote => 5,
226        ContentType::File => 6,
227        ContentType::FileChunk => 7,
228        ContentType::Poll => 8,
229    }
230}
231
232/// Inverse of [`content_type_to_u8`]. Unknown values fall back to `File`,
233/// matching [`ContentType::parse`]'s treatment of unrecognized strings.
234pub(crate) fn content_type_from_u8(v: u8) -> ContentType {
235    match v {
236        0 => ContentType::Text,
237        1 => ContentType::Image,
238        2 => ContentType::Video,
239        3 => ContentType::Audio,
240        4 => ContentType::VoiceNote,
241        5 => ContentType::VideoNote,
242        6 => ContentType::File,
243        7 => ContentType::FileChunk,
244        8 => ContentType::Poll,
245        _ => ContentType::File,
246    }
247}
248
249/// Encodes a [`WireMessageV1`] to a magic-prefixed binary frame.
250pub(crate) fn encode(wire: &WireMessageV1) -> crate::Result<Vec<u8>> {
251    let body = postcard::to_allocvec(wire)
252        .map_err(|e| crate::Error::SerializationError(format!("wire v1 encode: {e}")))?;
253    let mut out = Vec::with_capacity(body.len() + 1);
254    out.push(WIRE_V1_MAGIC);
255    out.extend_from_slice(&body);
256    Ok(out)
257}
258
259/// Decodes a magic-prefixed binary frame into a [`WireMessageV1`].
260///
261/// Returns an error if the leading magic byte is absent or does not match
262/// [`WIRE_V1_MAGIC`]; callers sniff the first byte before dispatching here.
263pub(crate) fn decode(data: &[u8]) -> crate::Result<WireMessageV1> {
264    match data.split_first() {
265        Some((&WIRE_V1_MAGIC, body)) => postcard::from_bytes::<WireMessageV1>(body)
266            .map_err(|e| crate::Error::DeserializationError(format!("wire v1 decode: {e}"))),
267        Some((other, _)) => Err(crate::Error::DeserializationError(format!(
268            "not a wire v1 frame: unexpected leading byte 0x{other:02X}"
269        ))),
270        None => Err(crate::Error::DeserializationError(
271            "not a wire v1 frame: empty input".to_string(),
272        )),
273    }
274}
275
276#[cfg(all(test, feature = "std"))]
277mod tests {
278    use super::*;
279    use crate::types::LAMPORT_CLOCK_MAX;
280    use crate::{
281        AppId, ForwardInfo, LamportClock, MediaMetadata, Message, MessageId, ReplyContext,
282        Timestamp, UserId, MAX_ID_LEN, TTL,
283    };
284
285    fn sample_message() -> Message {
286        let mut m = Message::new(
287            UserId::new("alice").unwrap(),
288            UserId::new("bob").unwrap(),
289            AppId::new("com.example.chat").unwrap(),
290            "hello world",
291        );
292        m.priority = MessagePriority::High;
293        m.ttl = TTL::new(5).unwrap();
294        m.content_type = ContentType::Image;
295        m.binary_content = Some(vec![0, 1, 2, 3, 255, 254]);
296        m.lamport_clock = LamportClock::from_value(42);
297        m.metadata.insert("k1".into(), "v1".into());
298        m.metadata.insert("k2".into(), "v2".into());
299        m.requires_ack = false;
300        m.reply_to_msg = Some(MessageId::new());
301        m.media_metadata = Some(MediaMetadata {
302            mime_type: "image/png".into(),
303            file_name: "x.png".into(),
304            file_size: 1234,
305            duration_ms: None,
306            width: Some(640),
307            height: Some(480),
308            thumbnail_base64: None,
309            media_id: Some("media-1".into()),
310            download_url: Some("https://cdn.example/media-1".into()),
311            thumbnail_url: None,
312            encryption_key: Some("a2V5".into()),
313            iv: None,
314            ciphertext_hash: None,
315            sticker_provider: None,
316            sticker_remote_id: None,
317            sticker_kind: None,
318        });
319        m.forwarded_from = Some(ForwardInfo {
320            original_sender: UserId::new("carol").unwrap(),
321            original_message_id: MessageId::new(),
322            original_timestamp: Timestamp::from_millis(1_700_000_000_000),
323            forward_count: 2,
324        });
325        m
326    }
327
328    /// A DTO with all-valid, minimal fields, for tampering in individual tests.
329    fn base_dto() -> WireMessageV1 {
330        WireMessageV1 {
331            id: [7u8; 16],
332            sender: "alice".into(),
333            recipient: "bob".into(),
334            app_id: "app".into(),
335            priority: 1,
336            ttl: 8,
337            hop_count: 0,
338            timestamp: 1,
339            lamport_clock: 0,
340            content_type: 0,
341            content: "hi".into(),
342            binary_content: None,
343            media_metadata_json: None,
344            metadata: Vec::new(),
345            requires_ack: true,
346            reply_to_msg: None,
347            forwarded_from_json: None,
348            ext: Vec::new(),
349        }
350    }
351
352    #[test]
353    fn wire_v1_round_trips_all_fields() {
354        let original = sample_message();
355        let bytes = original.to_wire_v1_bytes().unwrap();
356        assert_eq!(bytes[0], WIRE_V1_MAGIC);
357        let decoded = Message::from_wire_v1_bytes(&bytes).unwrap();
358
359        assert_eq!(decoded.id, original.id);
360        assert_eq!(decoded.sender, original.sender);
361        assert_eq!(decoded.recipient, original.recipient);
362        assert_eq!(decoded.app_id, original.app_id);
363        assert_eq!(decoded.priority, original.priority);
364        assert_eq!(decoded.ttl, original.ttl);
365        assert_eq!(decoded.hop_count, original.hop_count);
366        assert_eq!(decoded.timestamp, original.timestamp);
367        assert_eq!(decoded.lamport_clock, original.lamport_clock);
368        assert_eq!(decoded.content_type, original.content_type);
369        assert_eq!(decoded.content, original.content);
370        assert_eq!(decoded.binary_content, original.binary_content);
371        assert_eq!(decoded.metadata, original.metadata);
372        assert_eq!(decoded.requires_ack, original.requires_ack);
373        assert_eq!(decoded.reply_to_msg, original.reply_to_msg);
374        assert_eq!(decoded.forwarded_from, original.forwarded_from);
375        // MediaMetadata has no PartialEq; compare via its JSON form.
376        assert_eq!(
377            serde_json::to_vec(&decoded.media_metadata).unwrap(),
378            serde_json::to_vec(&original.media_metadata).unwrap(),
379        );
380        // transport_peer_id is never carried on the wire.
381        assert!(decoded.transport_peer_id().is_none());
382        // A decoded frame carries no wire-codec stamp: it defaults back to JSON
383        // so a relaying node re-negotiates the codec for its *next* hop rather
384        // than blindly re-emitting binary to a possibly-legacy peer.
385        assert_eq!(decoded.wire_codec(), crate::WireCodec::Json);
386    }
387
388    #[test]
389    fn wire_v1_round_trips_minimal_defaults() {
390        let original = Message::new(
391            UserId::new("a").unwrap(),
392            UserId::new("b").unwrap(),
393            AppId::new("c").unwrap(),
394            "hi",
395        );
396        let decoded = Message::from_wire_v1_bytes(&original.to_wire_v1_bytes().unwrap()).unwrap();
397        assert_eq!(decoded.id, original.id);
398        // Default `requires_ack = true` survives a round trip.
399        assert!(decoded.requires_ack);
400        assert!(decoded.binary_content.is_none());
401        assert!(decoded.media_metadata.is_none());
402        assert!(decoded.forwarded_from.is_none());
403        assert!(decoded.reply_to_msg.is_none());
404        assert!(decoded.metadata.is_empty());
405    }
406
407    #[test]
408    fn wire_v1_is_much_smaller_than_json() {
409        let m = Message::new(
410            UserId::new("a3f8c2d1-user-alice").unwrap(),
411            UserId::new("b7e4d9a2-user-bob").unwrap(),
412            AppId::new("com.example.chat").unwrap(),
413            "hi",
414        );
415        let json = m.to_bytes().unwrap();
416        let binary = m.to_wire_v1_bytes().unwrap();
417        assert!(
418            binary.len() * 2 < json.len(),
419            "binary {} should be < half of json {}",
420            binary.len(),
421            json.len()
422        );
423    }
424
425    #[test]
426    fn wire_v1_preserves_identifier_length_validation() {
427        // A binary frame carrying an over-long sender must be rejected exactly as
428        // the JSON custom-Deserialize path rejects it.
429        let mut wire = base_dto();
430        wire.sender = "a".repeat(MAX_ID_LEN + 1);
431        let bytes = encode(&wire).unwrap();
432        assert!(Message::from_wire_v1_bytes(&bytes).is_err());
433    }
434
435    #[test]
436    fn wire_v1_preserves_identifier_char_validation() {
437        let mut wire = base_dto();
438        wire.recipient = "evil/path".into();
439        let bytes = encode(&wire).unwrap();
440        assert!(Message::from_wire_v1_bytes(&bytes).is_err());
441    }
442
443    #[test]
444    fn wire_v1_clamps_adversarial_lamport_clock() {
445        let mut wire = base_dto();
446        wire.lamport_clock = u64::MAX;
447        let bytes = encode(&wire).unwrap();
448        let decoded = Message::from_wire_v1_bytes(&bytes).unwrap();
449        assert_eq!(decoded.lamport_clock.value(), LAMPORT_CLOCK_MAX);
450    }
451
452    #[test]
453    fn wire_v1_tolerates_unknown_enum_discriminants() {
454        let mut wire = base_dto();
455        wire.content_type = 200;
456        wire.priority = 200;
457        let bytes = encode(&wire).unwrap();
458        let decoded = Message::from_wire_v1_bytes(&bytes).unwrap();
459        assert_eq!(decoded.content_type, ContentType::File);
460        assert_eq!(decoded.priority, MessagePriority::Medium);
461    }
462
463    #[test]
464    fn wire_v1_round_trips_poll_content_type() {
465        let mut wire = base_dto();
466        wire.content_type = content_type_to_u8(ContentType::Poll);
467        let bytes = encode(&wire).unwrap();
468        let decoded = Message::from_wire_v1_bytes(&bytes).unwrap();
469        assert_eq!(decoded.content_type, ContentType::Poll);
470    }
471
472    #[test]
473    fn wire_v1_decode_rejects_non_v1_frames() {
474        assert!(decode(&[0x7B, 0x00]).is_err()); // a JSON '{'
475        assert!(decode(&[]).is_err()); // empty
476    }
477
478    #[test]
479    fn b64_tail_split_and_reconstruct_is_byte_identical() {
480        // 50 raw bytes -> 68 base64 chars incl. one '=' pad.
481        let raw: Vec<u8> = (0u8..50).collect();
482        let content = format!("__MLS_ENC__{}", BASE64.encode(&raw));
483        let (head, tail_raw) = split_b64_tail(&content).expect("tail must split");
484        assert_eq!(head, "__MLS_ENC__");
485        assert_eq!(tail_raw, raw);
486        assert_eq!(format!("{head}{}", encode_b64_tail(&tail_raw)), content);
487    }
488
489    #[test]
490    fn b64_tail_split_sheds_run_remainder_into_head_and_handles_headless() {
491        // A 66-char run: the leading 2 chars shed into the head so the tail
492        // stays 4-aligned, and reconstruction is still byte-identical.
493        let content = format!("!{}", "A".repeat(66));
494        let (head, raw) = split_b64_tail(&content).expect("aligned suffix splits");
495        assert_eq!(head, "!AA");
496        assert_eq!(format!("{head}{}", encode_b64_tail(&raw)), content);
497
498        // No head at all: the whole content is the tail.
499        let content = "A".repeat(64);
500        let (head, raw) = split_b64_tail(&content).expect("headless splits");
501        assert!(head.is_empty());
502        assert_eq!(encode_b64_tail(&raw), content);
503    }
504
505    #[test]
506    fn b64_tail_split_is_utf8_boundary_safe() {
507        let content = format!("héllo → {}", "A".repeat(64));
508        let (head, raw) = split_b64_tail(&content).expect("ascii tail after unicode head");
509        assert_eq!(head, "héllo → ");
510        assert_eq!(format!("{head}{}", encode_b64_tail(&raw)), content);
511    }
512
513    #[test]
514    fn b64_tail_split_rejects_short_nonaligned_and_noncanonical() {
515        // Too short overall.
516        assert!(split_b64_tail("AAAA").is_none());
517        // Run of 63: alignment trims to 60, under the 64-char minimum.
518        assert!(split_b64_tail(&format!("!{}", "A".repeat(63))).is_none());
519        // Legacy JSON int-array content: '}' and ']' break the run.
520        let json_like = format!("__MLS_ENC__{{\"ciphertext\":[{}]}}", "57,".repeat(60));
521        assert!(split_b64_tail(&json_like).is_none());
522        // Non-canonical tail (trailing bits / bogus padding) must not split:
523        // either the strict decode fails or the re-encode comparison does.
524        assert!(split_b64_tail(&format!("!{}=", "B".repeat(63))).is_none());
525        // Padding not at the very end breaks the run before it.
526        assert!(split_b64_tail(&format!("{}={}", "A".repeat(64), "A".repeat(3))).is_none());
527        // Only '=' beyond the minimum length: nothing decodable.
528        assert!(split_b64_tail(&"=".repeat(64)).is_none());
529    }
530
531    #[test]
532    fn wire_v1_round_trips_b64_tail_content_through_message() {
533        let mut m = Message::new(
534            UserId::new("alice").unwrap(),
535            UserId::new("bob").unwrap(),
536            AppId::new("app").unwrap(),
537            format!("__MLS_ENC__{}", BASE64.encode(vec![7u8; 90])),
538        );
539        m.requires_ack = true;
540        let bytes = m.to_wire_v1_bytes().unwrap();
541
542        // On the wire: content holds only the head, the raw tail rides in ext.
543        let wire = decode(&bytes).unwrap();
544        assert_eq!(wire.content, "__MLS_ENC__");
545        assert_eq!(wire.ext.len(), 1);
546        assert_eq!(wire.ext[0].0, EXT_TAG_B64_TAIL);
547        assert_eq!(wire.ext[0].1, vec![7u8; 90]);
548
549        // Decoded: byte-identical content, and the frame beats the base64 form.
550        let decoded = Message::from_wire_v1_bytes(&bytes).unwrap();
551        assert_eq!(decoded.content, m.content);
552        assert!(bytes.len() < m.to_bytes().unwrap().len());
553    }
554
555    #[test]
556    fn wire_v1_plain_content_emits_no_ext_entries() {
557        let m = sample_message();
558        let wire = decode(&m.to_wire_v1_bytes().unwrap()).unwrap();
559        assert!(wire.ext.is_empty());
560    }
561
562    #[test]
563    fn wire_v1_decode_ignores_unknown_ext_tags_and_extra_tag1_entries() {
564        // Unknown tags are skipped; only the FIRST tag-1 entry is honored, so
565        // a hostile frame cannot splice multiple tails into content.
566        let mut wire = base_dto();
567        wire.content = "head:".into();
568        wire.ext = vec![
569            (99, vec![1, 2, 3]),
570            (EXT_TAG_B64_TAIL, b"AB".to_vec()),
571            (EXT_TAG_B64_TAIL, b"ZZ".to_vec()),
572        ];
573        let bytes = encode(&wire).unwrap();
574        let decoded = Message::from_wire_v1_bytes(&bytes).unwrap();
575        assert_eq!(decoded.content, format!("head:{}", BASE64.encode(b"AB")));
576    }
577
578    fn sample_reply_context() -> ReplyContext {
579        ReplyContext {
580            sender: UserId::new("carol").unwrap(),
581            text: "original text".into(),
582            timestamp: Some(Timestamp::from_millis(1_700_000_000_000)),
583            reply_media_label: Some("x.png".into()),
584            reply_content_type: Some("image".into()),
585        }
586    }
587
588    #[test]
589    fn wire_v1_round_trips_reply_context() {
590        // Fully-populated context on an otherwise rich message.
591        let mut m = sample_message();
592        m.reply_context = Some(sample_reply_context());
593        let decoded = Message::from_wire_v1_bytes(&m.to_wire_v1_bytes().unwrap()).unwrap();
594        assert_eq!(decoded.reply_context, m.reply_context);
595
596        // Minimal context (all optional fields absent).
597        let mut m = Message::new(
598            UserId::new("a").unwrap(),
599            UserId::new("b").unwrap(),
600            AppId::new("c").unwrap(),
601            "hi",
602        );
603        m.reply_context = Some(ReplyContext {
604            sender: UserId::new("carol").unwrap(),
605            text: "quoted".into(),
606            timestamp: None,
607            reply_media_label: None,
608            reply_content_type: None,
609        });
610        let decoded = Message::from_wire_v1_bytes(&m.to_wire_v1_bytes().unwrap()).unwrap();
611        assert_eq!(decoded.reply_context, m.reply_context);
612    }
613
614    #[test]
615    fn wire_v1_reply_context_coexists_with_b64_tail() {
616        let mut m = Message::new(
617            UserId::new("alice").unwrap(),
618            UserId::new("bob").unwrap(),
619            AppId::new("app").unwrap(),
620            format!("__MLS_ENC__{}", BASE64.encode(vec![7u8; 90])),
621        );
622        m.reply_context = Some(sample_reply_context());
623        let bytes = m.to_wire_v1_bytes().unwrap();
624
625        let wire = decode(&bytes).unwrap();
626        assert_eq!(wire.ext.len(), 2);
627        assert_eq!(wire.ext[0].0, EXT_TAG_B64_TAIL);
628        assert_eq!(wire.ext[1].0, EXT_TAG_REPLY_CONTEXT);
629
630        let decoded = Message::from_wire_v1_bytes(&bytes).unwrap();
631        assert_eq!(decoded.content, m.content);
632        assert_eq!(decoded.reply_context, m.reply_context);
633    }
634
635    #[test]
636    fn wire_v1_honors_only_first_reply_context_entry() {
637        // A hostile frame cannot override an earlier reply context with a
638        // spliced later entry.
639        let mut wire = base_dto();
640        wire.ext = vec![
641            (
642                EXT_TAG_REPLY_CONTEXT,
643                br#"{"sender":"carol","text":"first"}"#.to_vec(),
644            ),
645            (
646                EXT_TAG_REPLY_CONTEXT,
647                br#"{"sender":"mallory","text":"second"}"#.to_vec(),
648            ),
649        ];
650        let decoded = Message::from_wire_v1_bytes(&encode(&wire).unwrap()).unwrap();
651        let rc = decoded.reply_context.expect("first entry honored");
652        assert_eq!(rc.sender.as_str(), "carol");
653        assert_eq!(rc.text, "first");
654    }
655
656    #[test]
657    fn wire_v1_rejects_malformed_reply_context_ext() {
658        // Parity with the JSON path: a malformed `reply_context` rejects the
659        // whole message rather than being silently dropped.
660        let mut wire = base_dto();
661        wire.ext = vec![(EXT_TAG_REPLY_CONTEXT, b"not json".to_vec())];
662        assert!(Message::from_wire_v1_bytes(&encode(&wire).unwrap()).is_err());
663
664        // Valid JSON, but a sender that fails UserId validation.
665        let mut wire = base_dto();
666        wire.ext = vec![(
667            EXT_TAG_REPLY_CONTEXT,
668            br#"{"sender":"evil/path","text":"t"}"#.to_vec(),
669        )];
670        assert!(Message::from_wire_v1_bytes(&encode(&wire).unwrap()).is_err());
671    }
672
673    #[test]
674    fn wire_v1_golden_layout_with_reply_context_ext_is_frozen() {
675        // Freezes the ext-TLV encoding for tag 2 (reply context JSON). Same
676        // contract as `wire_v1_golden_layout_is_frozen`: a mismatch means the
677        // wire format changed — bump the magic byte, do not edit the bytes.
678        let rc_json: &[u8] = br#"{"sender":"c","text":"t"}"#;
679        let mut wire = base_dto();
680        wire.content = String::new();
681        wire.ext = vec![(EXT_TAG_REPLY_CONTEXT, rc_json.to_vec())];
682        let mut expected_tail = vec![
683            0x01, // ext: len 1
684            0x02, // tag 2 (varint u16)
685            0x19, // value: len 25
686        ];
687        expected_tail.extend_from_slice(rc_json); // raw JSON bytes
688        let encoded = encode(&wire).unwrap();
689        assert_eq!(
690            &encoded[encoded.len() - expected_tail.len()..],
691            expected_tail
692        );
693        // And the decoder surfaces it as a validated ReplyContext.
694        let decoded = Message::from_wire_v1_bytes(&encoded).unwrap();
695        let rc = decoded.reply_context.expect("reply context decoded");
696        assert_eq!(rc.sender.as_str(), "c");
697        assert_eq!(rc.text, "t");
698    }
699
700    #[test]
701    fn wire_v1_golden_layout_with_b64_tail_ext_is_frozen() {
702        // Freezes the ext-TLV encoding for tag 1 (base64 content tail). Same
703        // contract as `wire_v1_golden_layout_is_frozen`: a mismatch means the
704        // wire format changed — bump the magic byte, do not edit the bytes.
705        let mut wire = base_dto();
706        wire.content = String::new();
707        wire.ext = vec![(EXT_TAG_B64_TAIL, vec![0xDE, 0xAD])];
708        let expected_tail: &[u8] = &[
709            0x01, // ext: len 1
710            0x01, // tag 1 (varint u16)
711            0x02, 0xDE, 0xAD, // value: len 2 + raw bytes
712        ];
713        let encoded = encode(&wire).unwrap();
714        assert_eq!(
715            &encoded[encoded.len() - expected_tail.len()..],
716            expected_tail
717        );
718        // And the decoder reattaches the tail as canonical base64.
719        let decoded = Message::from_wire_v1_bytes(&encoded).unwrap();
720        assert_eq!(decoded.content, BASE64.encode([0xDE, 0xAD]));
721    }
722
723    #[test]
724    fn wire_v1_golden_layout_is_frozen() {
725        // Freezes the exact postcard layout. If this assertion fails the wire
726        // format has changed: bump the magic byte and negotiate a new version —
727        // do NOT simply edit the expected bytes.
728        let wire = WireMessageV1 {
729            id: [0u8; 16],
730            sender: "a".into(),
731            recipient: "b".into(),
732            app_id: "c".into(),
733            priority: 1,
734            ttl: 8,
735            hop_count: 0,
736            timestamp: 0,
737            lamport_clock: 0,
738            content_type: 0,
739            content: String::new(),
740            binary_content: None,
741            media_metadata_json: None,
742            metadata: Vec::new(),
743            requires_ack: true,
744            reply_to_msg: None,
745            forwarded_from_json: None,
746            ext: Vec::new(),
747        };
748        let expected: &[u8] = &[
749            0xF5, // magic / version
750            0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // id (16 raw bytes)
751            0x01, b'a', // sender: len 1 + "a"
752            0x01, b'b', // recipient: len 1 + "b"
753            0x01, b'c', // app_id: len 1 + "c"
754            0x01, // priority
755            0x08, // ttl
756            0x00, // hop_count
757            0x00, // timestamp (zigzag varint 0)
758            0x00, // lamport_clock (varint 0)
759            0x00, // content_type
760            0x00, // content: len 0
761            0x00, // binary_content: None
762            0x00, // media_metadata_json: None
763            0x00, // metadata: len 0
764            0x01, // requires_ack: true
765            0x00, // reply_to_msg: None
766            0x00, // forwarded_from_json: None
767            0x00, // ext: len 0
768        ];
769        assert_eq!(encode(&wire).unwrap(), expected);
770    }
771}
772
773/// The frozen conformance vectors for the binary wire v1 frame.
774///
775/// The chapter these pin is `docs/spec/wire-format.md`. The vectors were
776/// computed from the primitive encoding table in that chapter by
777/// `tools/spec-vectors/generate.py`, a second implementation that never reads
778/// this crate, rather than by running the codec below: a vector generated from
779/// `encode` would agree with any format this crate happened to emit, including
780/// a wrong one.
781///
782/// When one of these fails the wire format has changed. That needs a new magic
783/// byte and a negotiated version, not an edited expectation: editing the
784/// expected value to make a test pass converts a caught break into a shipped
785/// one.
786#[cfg(all(test, feature = "std"))]
787mod spec_vectors {
788    use super::*;
789    use crate::types::LAMPORT_CLOCK_MAX;
790    use crate::{AppId, ContentType, MessageId, MessagePriority, Timestamp, UserId, MAX_ID_LEN};
791    use crate::{HopCount, LamportClock, Message, ReplyContext, TTL};
792
793    const VECTORS: &str = include_str!("../tests/data/wire-v1.vectors.json");
794
795    fn vectors() -> serde_json::Value {
796        serde_json::from_str(VECTORS).expect("the vector file is JSON")
797    }
798
799    fn unhex(s: &str) -> Vec<u8> {
800        assert!(s.len() % 2 == 0, "hex has an even length");
801        (0..s.len())
802            .step_by(2)
803            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hex digit"))
804            .collect()
805    }
806
807    fn hex(bytes: &[u8]) -> String {
808        bytes.iter().map(|b| format!("{b:02x}")).collect()
809    }
810
811    /// Builds the message a `frames` case describes.
812    ///
813    /// Every field is taken from the vector rather than defaulted, so a field
814    /// the generator stops emitting fails here instead of silently encoding
815    /// this crate's own default on both sides of the comparison.
816    fn message_from(spec: &serde_json::Value) -> Message {
817        let s = |k: &str| spec[k].as_str().expect("a string field").to_string();
818        let mut m = Message::from_parts(
819            MessageId::from_str(&s("id")).expect("a uuid"),
820            UserId::new(s("sender")).expect("a sender"),
821            UserId::new(s("recipient")).expect("a recipient"),
822            AppId::new(s("app_id")).expect("an app id"),
823            s("content"),
824            Timestamp::from_millis(spec["timestamp_ms"].as_i64().expect("an instant")),
825        );
826
827        m.priority = match spec["priority"].as_str().expect("a priority") {
828            "low" => MessagePriority::Low,
829            "medium" => MessagePriority::Medium,
830            "high" => MessagePriority::High,
831            "critical" => MessagePriority::Critical,
832            other => panic!("the vector names a priority that does not exist: {other}"),
833        };
834        m.ttl = TTL::from_value(spec["ttl"].as_u64().expect("a ttl") as u8);
835        m.hop_count = HopCount::from_value(spec["hop_count"].as_u64().expect("a hop count") as u8);
836        m.lamport_clock =
837            LamportClock::from_value(spec["lamport_clock"].as_u64().expect("a lamport clock"));
838        m.content_type = ContentType::parse(spec["content_type"].as_str().expect("a content type"));
839        m.requires_ack = spec["requires_ack"].as_bool().expect("requires_ack");
840
841        if let Some(h) = spec["binary_content_hex"].as_str() {
842            m.binary_content = Some(unhex(h));
843        }
844        if let Some(j) = spec["media_metadata_json"].as_str() {
845            m.media_metadata = Some(serde_json::from_str(j).expect("media metadata"));
846        }
847        if let Some(j) = spec["forwarded_from_json"].as_str() {
848            m.forwarded_from = Some(serde_json::from_str(j).expect("forward attribution"));
849        }
850        if let Some(j) = spec["reply_context_json"].as_str() {
851            m.reply_context = Some(serde_json::from_str::<ReplyContext>(j).expect("reply context"));
852        }
853        if let Some(id) = spec["reply_to_msg"].as_str() {
854            m.reply_to_msg = Some(MessageId::from_str(id).expect("a reply id"));
855        }
856        for (k, v) in spec["metadata"].as_object().expect("a metadata map") {
857            m.metadata
858                .insert(k.clone(), v.as_str().expect("a metadata value").to_string());
859        }
860        m
861    }
862
863    /// Asserts the file still carries what it carried, before anything iterates
864    /// it.
865    ///
866    /// Every test below loops over an array, and a loop over an array that a
867    /// bad merge emptied passes by not running. This is the only assertion here
868    /// that fails when the file is truncated rather than wrong.
869    #[test]
870    fn the_vector_file_is_the_size_it_was() {
871        let v = vectors();
872        assert_eq!(v["magic"], "f5");
873        assert_eq!(v["frames"].as_array().expect("frames").len(), 18);
874        assert_eq!(v["decode_only"].as_array().expect("decode_only").len(), 6);
875        assert_eq!(v["rejects"].as_array().expect("rejects").len(), 5);
876    }
877
878    #[test]
879    fn every_frame_encodes_to_its_vector() {
880        for case in vectors()["frames"].as_array().expect("frames") {
881            let name = case["name"].as_str().expect("a name");
882            let encoded = message_from(&case["message"])
883                .to_wire_v1_bytes()
884                .unwrap_or_else(|e| panic!("[{name}] did not encode: {e}"));
885            assert_eq!(
886                hex(&encoded),
887                case["hex"].as_str().expect("expected hex"),
888                "[{name}] encoded to different bytes than the chapter specifies"
889            );
890        }
891    }
892
893    /// Every frame vector decodes back into the message it was built from.
894    ///
895    /// Encoding alone would pass for a codec whose decoder disagreed with it,
896    /// which is the shape a positional format fails in: a reordered field
897    /// round-trips against itself perfectly.
898    #[test]
899    fn every_frame_decodes_back_into_its_message() {
900        for case in vectors()["frames"].as_array().expect("frames") {
901            let name = case["name"].as_str().expect("a name");
902            let want = message_from(&case["message"]);
903            let got = Message::from_wire_v1_bytes(&unhex(case["hex"].as_str().expect("hex")))
904                .unwrap_or_else(|e| panic!("[{name}] did not decode: {e}"));
905
906            assert_eq!(got.id, want.id, "[{name}] id");
907            assert_eq!(got.sender, want.sender, "[{name}] sender");
908            assert_eq!(got.recipient, want.recipient, "[{name}] recipient");
909            assert_eq!(got.app_id, want.app_id, "[{name}] app_id");
910            assert_eq!(got.priority, want.priority, "[{name}] priority");
911            assert_eq!(got.ttl.value(), want.ttl.value(), "[{name}] ttl");
912            assert_eq!(
913                got.hop_count.value(),
914                want.hop_count.value(),
915                "[{name}] hop_count"
916            );
917            assert_eq!(
918                got.timestamp.as_millis(),
919                want.timestamp.as_millis(),
920                "[{name}] timestamp"
921            );
922            assert_eq!(
923                got.lamport_clock.value(),
924                want.lamport_clock.value(),
925                "[{name}] lamport_clock"
926            );
927            assert_eq!(got.content_type, want.content_type, "[{name}] content_type");
928            assert_eq!(got.content, want.content, "[{name}] content");
929            assert_eq!(
930                got.binary_content, want.binary_content,
931                "[{name}] binary_content"
932            );
933            assert_eq!(got.metadata, want.metadata, "[{name}] metadata");
934            assert_eq!(got.requires_ack, want.requires_ack, "[{name}] requires_ack");
935            assert_eq!(got.reply_to_msg, want.reply_to_msg, "[{name}] reply_to_msg");
936            // The three that ride as embedded JSON. Their *bytes* are not
937            // pinned by this suite, because they evolve under their own
938            // additive rules rather than the frozen wire contract, but a
939            // decoder that dropped one on the way back would otherwise pass
940            // every assertion here while losing the field.
941            //
942            // `MediaMetadata` carries no `PartialEq`, so it is compared as
943            // serialized bytes, which is what the round-trip tests above this
944            // module do for the same reason.
945            assert_eq!(
946                serde_json::to_vec(&got.media_metadata).expect("it serializes"),
947                serde_json::to_vec(&want.media_metadata).expect("it serializes"),
948                "[{name}] media_metadata"
949            );
950            assert_eq!(
951                got.forwarded_from, want.forwarded_from,
952                "[{name}] forwarded_from"
953            );
954            assert_eq!(
955                got.reply_context, want.reply_context,
956                "[{name}] reply_context"
957            );
958        }
959    }
960
961    /// The cases no conforming encoder of this version can produce.
962    ///
963    /// An unknown discriminant is what a *later* sender emits, so these can
964    /// only be pinned from the wire inward.
965    #[test]
966    fn decode_only_vectors_decode_as_specified() {
967        for case in vectors()["decode_only"].as_array().expect("decode_only") {
968            let name = case["name"].as_str().expect("a name");
969            let got = Message::from_wire_v1_bytes(&unhex(case["hex"].as_str().expect("hex")))
970                .unwrap_or_else(|e| panic!("[{name}] did not decode: {e}"));
971            let expect = &case["expect"];
972
973            if let Some(want) = expect["content_type"].as_str() {
974                assert_eq!(
975                    got.content_type.as_wire_str(),
976                    want,
977                    "[{name}] content_type"
978                );
979            }
980            if let Some(want) = expect["priority"].as_str() {
981                let got = match got.priority {
982                    MessagePriority::Low => "low",
983                    MessagePriority::Medium => "medium",
984                    MessagePriority::High => "high",
985                    MessagePriority::Critical => "critical",
986                };
987                assert_eq!(got, want, "[{name}] priority");
988            }
989            if let Some(want) = expect["lamport_clock"].as_u64() {
990                assert_eq!(got.lamport_clock.value(), want, "[{name}] lamport_clock");
991            }
992            if let Some(want) = expect["content"].as_str() {
993                assert_eq!(got.content, want, "[{name}] content");
994            }
995        }
996    }
997
998    #[test]
999    fn reject_vectors_are_refused() {
1000        for case in vectors()["rejects"].as_array().expect("rejects") {
1001            let name = case["name"].as_str().expect("a name");
1002            let reason = case["reason"].as_str().expect("a reason");
1003            let bytes = unhex(case["hex"].as_str().expect("hex"));
1004            assert!(
1005                Message::from_wire_v1_bytes(&bytes).is_err(),
1006                "[{name}] was accepted, but the chapter refuses it: {reason}"
1007            );
1008        }
1009    }
1010
1011    /// The chapter, or `None` where the repo tree is absent.
1012    ///
1013    /// Read at runtime rather than with `include_str!` because the chapter
1014    /// lives outside the package root: `cargo package` carries `tests/` and the
1015    /// vector file but cannot carry `docs/`, so compiling the path in would
1016    /// leave the published crate's tests unable to build at all.
1017    fn chapter() -> Option<String> {
1018        let path =
1019            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/spec/wire-format.md");
1020        std::fs::read_to_string(&path).ok().or_else(|| {
1021            eprintln!("spec tree not present, skipping the wire-format chapter drift checks");
1022            None
1023        })
1024    }
1025
1026    /// The chapter states the constants this code uses.
1027    ///
1028    /// This is the drift that would otherwise leave a correct implementation
1029    /// reading a stale document, which is worse than no document: the reader
1030    /// has no reason to doubt it.
1031    #[test]
1032    fn the_chapter_states_the_constants_the_code_uses() {
1033        let Some(text) = chapter() else { return };
1034
1035        assert!(
1036            text.contains(&format!("`0x{:02X}` | Binary wire v1", WIRE_V1_MAGIC)),
1037            "the chapter's encoding-detection table does not state the magic byte as 0x{WIRE_V1_MAGIC:02X}"
1038        );
1039        assert!(
1040            text.contains(&format!(
1041                "{B64_TAIL_MIN_LEN} base64 characters"
1042            )),
1043            "the chapter does not state the {B64_TAIL_MIN_LEN}-character minimum for the tag 1 split"
1044        );
1045        assert!(
1046            text.contains(&format!("`{MAX_ID_LEN}`"))
1047                || text.contains(&format!("{MAX_ID_LEN}-byte")),
1048            "the chapter does not state the {MAX_ID_LEN}-byte identifier cap"
1049        );
1050    }
1051
1052    /// The chapter states the primitive encoding at all.
1053    ///
1054    /// This is the omission the vectors exist because of: the field-order block
1055    /// alone reads as fixed-width to anyone who has not seen the codec, and an
1056    /// implementation built from it is misaligned from the first varint. A
1057    /// future edit that trims the table back to field order would restore that
1058    /// gap silently, so the presence of each rule is asserted rather than
1059    /// assumed.
1060    #[test]
1061    fn the_chapter_states_how_a_primitive_becomes_bytes() {
1062        let Some(text) = chapter() else { return };
1063
1064        for required in [
1065            "Zigzag",
1066            "varint",
1067            "little-endian base 128",
1068            "no length prefix",
1069            // The metadata order. Byte order and code-point order are the same
1070            // order, so the only way to state this rule usefully is to name the
1071            // order that differs, and a future trim that drops the warning
1072            // leaves the surviving text true and useless.
1073            "UTF-16 code-unit order",
1074        ] {
1075            assert!(
1076                text.contains(required),
1077                "the binary chapter no longer states {required:?}, without which \
1078                 the field-order block cannot be implemented"
1079            );
1080        }
1081    }
1082
1083    /// The clamp the decode vectors rely on is the one the type enforces.
1084    #[test]
1085    fn the_lamport_clamp_the_vectors_pin_is_the_types_own() {
1086        let v = vectors();
1087        let case = v["decode_only"]
1088            .as_array()
1089            .expect("decode_only")
1090            .iter()
1091            .find(|c| c["expect"]["lamport_clock"].is_u64())
1092            .expect("a clamp vector");
1093        assert_eq!(
1094            case["expect"]["lamport_clock"].as_u64().expect("a clamp"),
1095            LAMPORT_CLOCK_MAX,
1096            "the vector pins a ceiling the type does not enforce"
1097        );
1098    }
1099}