Skip to main content

offline_protocol_core/
message.rs

1//! Message types and structures.
2
3use crate::types::{AppId, HopCount, LamportClock, Timestamp, UserId, TTL};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::fmt;
7use uuid::Uuid;
8
9/// Unique message identifier.
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub struct MessageId(Uuid);
12
13impl MessageId {
14    /// Generates a new random message ID.
15    pub fn new() -> Self {
16        Self(Uuid::new_v4())
17    }
18
19    /// Creates a message ID from a string representation.
20    #[allow(clippy::should_implement_trait)]
21    pub fn from_str(id: &str) -> crate::Result<Self> {
22        let uuid = Uuid::parse_str(id)
23            .map_err(|e| crate::Error::InvalidMessage(format!("Invalid message id: {}", e)))?;
24        Ok(Self::from_uuid(uuid))
25    }
26
27    /// Creates a message ID from a UUID.
28    pub fn from_uuid(uuid: Uuid) -> Self {
29        Self(uuid)
30    }
31
32    /// Returns the message ID as a string.
33    pub fn as_str(&self) -> String {
34        self.0.to_string()
35    }
36
37    /// Returns the 16-byte representation of the underlying UUID.
38    ///
39    /// Used by the binary wire codec to carry the id as 16 raw bytes instead of
40    /// the 36-character hyphenated string the JSON encoding uses.
41    pub fn as_bytes(&self) -> [u8; 16] {
42        *self.0.as_bytes()
43    }
44
45    /// Reconstructs a message id from its 16-byte representation.
46    pub fn from_bytes(bytes: [u8; 16]) -> Self {
47        Self(Uuid::from_bytes(bytes))
48    }
49}
50
51impl Default for MessageId {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl fmt::Display for MessageId {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        write!(f, "{}", self.0)
60    }
61}
62
63/// The type of content carried in a message.
64///
65/// Adding a variant is an additive wire change: older receivers must keep
66/// decoding messages that carry a type they don't know. All three decode
67/// paths therefore share the same fallback — an unrecognised value degrades
68/// to [`File`](Self::File) instead of failing the containing message:
69/// [`parse`](Self::parse) for strings, `content_type_from_u8` for the binary
70/// wire codec, and the hand-written [`Deserialize`] below for JSON (the
71/// derived impl would reject the whole frame on an unknown variant).
72///
73/// Both serde impls are hand-written over [`as_wire_str`](Self::as_wire_str)
74/// rather than derived, so *every* serde format — not only the
75/// self-describing ones — speaks the same strings as the other two decode
76/// paths. A derived `Serialize` would emit the variant *index* in a
77/// non-self-describing format (postcard, bincode), which the tolerant
78/// string-based `Deserialize` would then read back as a zero-length string
79/// and degrade to `File` — silently turning `Text` into `File` with no
80/// error. Nothing in-tree does that today (the binary wire codec maps to
81/// `u8` by hand), but this type is published, so the two directions are
82/// kept symmetric in all formats rather than only in JSON.
83///
84/// Apps should treat `File` as "a file *or* a type this build doesn't
85/// know". A degraded value keeps [`is_media`](Self::is_media) `true` while
86/// carrying no [`MediaMetadata`] and no transfer, so render that
87/// combination as a generic unsupported-content placeholder rather than as
88/// a broken attachment.
89#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
90pub enum ContentType {
91    /// Plain text message.
92    #[default]
93    Text,
94    /// Image attachment (JPEG, PNG, etc.).
95    Image,
96    /// Video attachment.
97    Video,
98    /// Audio attachment.
99    Audio,
100    /// Short voice recording.
101    VoiceNote,
102    /// Short video recording.
103    VideoNote,
104    /// Generic file attachment.
105    File,
106    /// Internal: a chunk belonging to a multi-part file transfer.
107    FileChunk,
108    /// Structured poll, rendered by the app rather than as plain text.
109    Poll,
110}
111
112impl ContentType {
113    /// Every variant, in declaration order.
114    ///
115    /// Provably complete — see the `const` block below — which is what lets
116    /// `content_type_json_round_trips_every_variant` stand in for the
117    /// compiler over [`parse`](Self::parse).
118    pub const ALL: [Self; 9] = [
119        Self::Text,
120        Self::Image,
121        Self::Video,
122        Self::Audio,
123        Self::VoiceNote,
124        Self::VideoNote,
125        Self::File,
126        Self::FileChunk,
127        Self::Poll,
128    ];
129
130    /// Returns `true` for types that carry binary media data.
131    pub fn is_media(&self) -> bool {
132        !matches!(self, Self::Text | Self::Poll)
133    }
134
135    /// The canonical wire string for this type.
136    ///
137    /// The single source of truth for the variant → string direction,
138    /// shared by [`Display`](fmt::Display), [`Serialize`] and the JSON
139    /// [`Deserialize`]. Exhaustive, so rustc — not a test — guarantees
140    /// every variant has exactly one wire string.
141    pub const fn as_wire_str(self) -> &'static str {
142        match self {
143            Self::Text => "text",
144            Self::Image => "image",
145            Self::Video => "video",
146            Self::Audio => "audio",
147            Self::VoiceNote => "voice_note",
148            Self::VideoNote => "video_note",
149            Self::File => "file",
150            Self::FileChunk => "file_chunk",
151            Self::Poll => "poll",
152        }
153    }
154
155    /// Parses a content type from its string representation.
156    ///
157    /// Falls back to `File` for unrecognised strings. This is the inverse of
158    /// [`as_wire_str`](Self::as_wire_str), and the one mapping rustc cannot
159    /// check: the `_` arm swallows a variant whose arm was forgotten, which
160    /// would make that variant decode as `File` even between same-version
161    /// peers. `content_type_json_round_trips_every_variant` is what pins it,
162    /// iterating [`ALL`](Self::ALL) so it cannot silently miss a variant.
163    pub fn parse(s: &str) -> Self {
164        match s {
165            "text" => Self::Text,
166            "image" => Self::Image,
167            "video" => Self::Video,
168            "audio" => Self::Audio,
169            "voice_note" => Self::VoiceNote,
170            "video_note" => Self::VideoNote,
171            "file" => Self::File,
172            "file_chunk" => Self::FileChunk,
173            "poll" => Self::Poll,
174            _ => Self::File,
175        }
176    }
177}
178
179/// Pins [`ContentType::ALL`] complete and correctly ordered at compile time.
180///
181/// Adding a variant fails to compile twice over: the match below is
182/// exhaustive, so the new variant must be named here, and the arm naming it
183/// then indexes past the end of `ALL` (a deny-by-default `unconditional_panic`
184/// error) until the array grows too. The loop then const-evaluates each entry,
185/// pinning position as well as membership.
186///
187/// This matters because `ALL` is what the round-trip test iterates to cover
188/// [`ContentType::parse`]'s unavoidable `_` arm. A hand-maintained list would
189/// leave "forgot the `parse` arm" and "forgot the test entry" as the same
190/// omission, so the test could never catch it.
191const _: () = {
192    const fn listed_at_own_index(ct: ContentType) -> bool {
193        match ct {
194            ContentType::Text => matches!(ContentType::ALL[0], ContentType::Text),
195            ContentType::Image => matches!(ContentType::ALL[1], ContentType::Image),
196            ContentType::Video => matches!(ContentType::ALL[2], ContentType::Video),
197            ContentType::Audio => matches!(ContentType::ALL[3], ContentType::Audio),
198            ContentType::VoiceNote => matches!(ContentType::ALL[4], ContentType::VoiceNote),
199            ContentType::VideoNote => matches!(ContentType::ALL[5], ContentType::VideoNote),
200            ContentType::File => matches!(ContentType::ALL[6], ContentType::File),
201            ContentType::FileChunk => matches!(ContentType::ALL[7], ContentType::FileChunk),
202            ContentType::Poll => matches!(ContentType::ALL[8], ContentType::Poll),
203        }
204    }
205
206    let mut i = 0;
207    while i < ContentType::ALL.len() {
208        assert!(listed_at_own_index(ContentType::ALL[i]));
209        i += 1;
210    }
211};
212
213/// Serializes as the [`wire string`](ContentType::as_wire_str), in every
214/// format rather than only the self-describing ones. Byte-identical to the
215/// derived `snake_case` impl on JSON; see the type docs for why the derived
216/// impl is not used.
217impl Serialize for ContentType {
218    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
219    where
220        S: serde::Serializer,
221    {
222        serializer.serialize_str(self.as_wire_str())
223    }
224}
225
226/// Tolerant decoding: any string is accepted and routed through
227/// [`ContentType::parse`], so a variant added by a newer sender degrades to
228/// `File` on this receiver rather than failing the whole `Message` (or
229/// sealed rich body) it rides in. Non-string values still error — that is
230/// malformed data, not additive evolution.
231impl<'de> Deserialize<'de> for ContentType {
232    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
233    where
234        D: serde::Deserializer<'de>,
235    {
236        struct ContentTypeVisitor;
237
238        impl serde::de::Visitor<'_> for ContentTypeVisitor {
239            type Value = ContentType;
240
241            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242                f.write_str("a content type string")
243            }
244
245            fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<ContentType, E> {
246                Ok(ContentType::parse(s))
247            }
248        }
249
250        deserializer.deserialize_str(ContentTypeVisitor)
251    }
252}
253
254impl fmt::Display for ContentType {
255    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256        f.write_str(self.as_wire_str())
257    }
258}
259
260/// Metadata describing a media attachment.
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct MediaMetadata {
263    /// MIME type (e.g. "image/jpeg", "video/mp4").
264    pub mime_type: String,
265
266    /// Original file name.
267    pub file_name: String,
268
269    /// File size in bytes.
270    pub file_size: u64,
271
272    /// Duration in milliseconds (audio/video/voice-note/video-note).
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub duration_ms: Option<u64>,
275
276    /// Width in pixels (images and video).
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub width: Option<u32>,
279
280    /// Height in pixels (images and video).
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub height: Option<u32>,
283
284    /// Small base64-encoded thumbnail (< 2 KB) for preview.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub thumbnail_base64: Option<String>,
287
288    /// Stable media identifier assigned by the application.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub media_id: Option<String>,
291
292    /// URL to fetch the full media from (cloud-stored media).
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub download_url: Option<String>,
295
296    /// URL to fetch a thumbnail from (cloud-stored media).
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub thumbnail_url: Option<String>,
299
300    /// Content-encryption key for cloud-stored media (base64).
301    ///
302    /// Secret material: must only travel inside an end-to-end-encrypted
303    /// payload, never in cleartext message fields visible to relays.
304    /// Enforced at the transport wire chokepoint, which strips it (via
305    /// [`MediaMetadata::without_secrets`]) from every outbound cleartext
306    /// frame, and by the telemetry scrubber, which redacts it
307    /// unconditionally before events reach a sink.
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub encryption_key: Option<String>,
310
311    /// Initialization vector for the cloud-media content encryption (base64).
312    /// Secret material — same handling as [`Self::encryption_key`].
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub iv: Option<String>,
315
316    /// Integrity hash of the encrypted cloud-media blob (base64).
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub ciphertext_hash: Option<String>,
319
320    /// Sticker pack provider (sticker messages).
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub sticker_provider: Option<String>,
323
324    /// Provider-scoped sticker identifier (sticker messages).
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub sticker_remote_id: Option<String>,
327
328    /// Sticker rendering kind (e.g. "static", "animated", "lottie").
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub sticker_kind: Option<String>,
331}
332
333impl MediaMetadata {
334    /// True when any confidential field ([`Self::encryption_key`],
335    /// [`Self::iv`]) is set.
336    pub fn has_secrets(&self) -> bool {
337        self.encryption_key.is_some() || self.iv.is_some()
338    }
339
340    /// Returns a copy with the confidential fields cleared.
341    ///
342    /// The cloud-media content key (and its IV) grant access to the media
343    /// itself, so they must only ever travel inside an end-to-end-encrypted
344    /// payload. Every path that writes metadata into a cleartext surface —
345    /// the transport wire encoders, the telemetry event scrubber — routes
346    /// through this before emitting. The remaining fields (URLs, hashes,
347    /// sticker identity, dimensions) are display hints, not secrets, and are
348    /// preserved.
349    pub fn without_secrets(&self) -> Self {
350        Self {
351            encryption_key: None,
352            iv: None,
353            ..self.clone()
354        }
355    }
356}
357
358/// Information about a forwarded message.
359///
360/// **Trust model:** `ForwardInfo` is an unverified attribution hint, not a
361/// cryptographic proof. A malicious client can forge the `original_sender` or
362/// reset `forward_count`. UI layers should treat this as a display-level hint
363/// and must not rely on it for access-control or security decisions.
364#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
365pub struct ForwardInfo {
366    /// The original sender of the message.
367    pub original_sender: UserId,
368    /// The original message ID.
369    pub original_message_id: MessageId,
370    /// The original timestamp (wall-clock, for display).
371    pub original_timestamp: Timestamp,
372    /// Number of times this message has been forwarded.
373    pub forward_count: u32,
374}
375
376impl ForwardInfo {
377    /// Creates `ForwardInfo` from a message being forwarded.
378    ///
379    /// If the message was already forwarded, the original attribution is preserved
380    /// and `forward_count` is incremented. Otherwise, the message's sender becomes
381    /// the original sender with `forward_count = 1`.
382    pub fn from_message(message: &Message) -> Self {
383        match &message.forwarded_from {
384            Some(existing) => ForwardInfo {
385                original_sender: existing.original_sender.clone(),
386                original_message_id: existing.original_message_id.clone(),
387                original_timestamp: existing.original_timestamp,
388                forward_count: existing.forward_count.saturating_add(1),
389            },
390            None => ForwardInfo {
391                original_sender: message.sender.clone(),
392                original_message_id: message.id.clone(),
393                original_timestamp: message.timestamp,
394                forward_count: 1,
395            },
396        }
397    }
398}
399
400/// Quoted-reply context for rendering a reply preview without a local copy
401/// of the original message.
402///
403/// **Trust model:** like [`ForwardInfo`], this is an unverified display-level
404/// hint copied by the sending client. A malicious client can forge any field.
405/// UI layers should treat this as a display-level hint and must not rely on
406/// it for access-control or security decisions.
407///
408/// On an MLS-encrypted message this outer field additionally sits outside
409/// the AEAD boundary (any relay hop can inject or rewrite it in transit),
410/// so the protocol layer drops it on decryption: it never surfaces on an
411/// `encrypted: true` event. The end-to-end-sealed envelope is the only
412/// trusted carrier for reply context on encrypted messages.
413#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
414pub struct ReplyContext {
415    /// Sender of the message being replied to.
416    pub sender: UserId,
417    /// Text (or excerpt) of the message being replied to.
418    pub text: String,
419    /// Timestamp of the quoted message (wall-clock, for display).
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub timestamp: Option<Timestamp>,
422    /// Short human-readable label for quoted media (e.g. a file name).
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub reply_media_label: Option<String>,
425    /// Content type of the quoted message, as a display string (e.g. "image").
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub reply_content_type: Option<String>,
428}
429
430/// Message priority levels.
431///
432/// Serializes lowercase; the capitalized aliases keep Debug-cased values
433/// parseable — the UniFFI `receive_message` JSON has always emitted
434/// `"Medium"` etc., and that JSON must round-trip back through
435/// `forward_message`.
436#[derive(
437    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
438)]
439#[serde(rename_all = "lowercase")]
440pub enum MessagePriority {
441    /// Low priority - can be delayed or dropped under congestion.
442    #[serde(alias = "Low")]
443    Low,
444    /// Medium priority - default for most messages.
445    #[default]
446    #[serde(alias = "Medium")]
447    Medium,
448    /// High priority - important messages that should be delivered quickly.
449    #[serde(alias = "High")]
450    High,
451    /// Critical priority - emergency messages, highest delivery guarantee.
452    #[serde(alias = "Critical")]
453    Critical,
454}
455
456impl MessagePriority {
457    /// Returns a numeric score for the priority (higher = more important).
458    pub fn score(&self) -> u8 {
459        match self {
460            Self::Low => 1,
461            Self::Medium => 2,
462            Self::High => 3,
463            Self::Critical => 4,
464        }
465    }
466
467    /// Returns a stable string representation suitable for event payloads.
468    pub fn as_str(&self) -> &'static str {
469        match self {
470            Self::Low => "Low",
471            Self::Medium => "Medium",
472            Self::High => "High",
473            Self::Critical => "Critical",
474        }
475    }
476}
477
478/// Wire codec selected for encoding a message on its next send.
479///
480/// Carried in-process only (never serialized) and defaults to
481/// [`WireCodec::Json`], the universally-understood encoding. The transport
482/// manager stamps [`WireCodec::BinaryV1`] just before send when the recipient is
483/// known to support the compact binary format; mesh transports honor the stamp
484/// at drain time while the internet transport always emits JSON.
485#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
486pub enum WireCodec {
487    /// Legacy JSON encoding — the permanent interoperability floor.
488    #[default]
489    Json,
490    /// Compact binary wire codec, version 1 (see [`crate::wire`]).
491    BinaryV1,
492}
493
494/// A message in the Offline Protocol.
495#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct Message {
497    /// Unique message identifier.
498    pub id: MessageId,
499
500    /// Sender's user ID.
501    pub sender: UserId,
502
503    /// Recipient's user ID.
504    pub recipient: UserId,
505
506    /// Application ID that this message belongs to.
507    pub app_id: AppId,
508
509    /// Message priority.
510    pub priority: MessagePriority,
511
512    /// Time-to-live: maximum hops remaining.
513    pub ttl: TTL,
514
515    /// Number of hops this message has traversed.
516    pub hop_count: HopCount,
517
518    /// Timestamp when the message was created (wall-clock, for display only).
519    pub timestamp: Timestamp,
520
521    /// Lamport logical clock for causal ordering across devices.
522    #[serde(default)]
523    pub lamport_clock: LamportClock,
524
525    /// The type of content this message carries.
526    #[serde(default)]
527    pub content_type: ContentType,
528
529    /// Message content (text, JSON-serialized file chunk metadata, etc.).
530    pub content: String,
531
532    /// Raw payload for file chunk data carried by the message envelope.
533    /// This avoids wrapping chunk bytes in `FileChunk` JSON/base64 content.
534    /// Note: current transport serialization still uses JSON for `Message`.
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub binary_content: Option<Vec<u8>>,
537
538    /// Media metadata (present for non-text content types).
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub media_metadata: Option<MediaMetadata>,
541
542    /// Optional metadata for application-specific use.
543    #[serde(default)]
544    pub metadata: HashMap<String, String>,
545
546    /// Whether this message requires an ACK.
547    #[serde(default = "default_requires_ack")]
548    pub requires_ack: bool,
549
550    /// ID of the message this is replying to (optional).
551    #[serde(default)]
552    pub reply_to_msg: Option<MessageId>,
553
554    /// Forwarding attribution (present when this message was forwarded from another user).
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub forwarded_from: Option<ForwardInfo>,
557
558    /// Quoted-reply context (present when this message quotes another).
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub reply_context: Option<ReplyContext>,
561
562    /// Transport-verified peer identity.
563    ///
564    /// Set by the transport layer when a message is received, binding the message
565    /// to the physical peer that delivered it. This field is **never serialized**
566    /// over the wire — it exists only in-process to prevent sender spoofing.
567    ///
568    /// When `Some`, the protocol layer validates that `sender` matches this value
569    /// before processing security-sensitive control messages.
570    ///
571    /// # Security
572    ///
573    /// This field is private to prevent application code from bypassing sender
574    /// verification. Use [`set_transport_peer_id`](Self::set_transport_peer_id)
575    /// (transport layers only) and [`transport_peer_id`](Self::transport_peer_id)
576    /// to interact with it.
577    #[serde(skip)]
578    transport_peer_id: Option<String>,
579
580    /// Wire codec to use when this message is next serialized for the wire.
581    ///
582    /// In-process only (`#[serde(skip)]`); stamped by the transport manager from
583    /// per-peer capability just before send. Defaults to [`WireCodec::Json`].
584    #[serde(skip)]
585    wire_codec: WireCodec,
586}
587
588fn default_requires_ack() -> bool {
589    true
590}
591
592impl Message {
593    /// Creates a new message.
594    ///
595    /// # Arguments
596    ///
597    /// * `sender` - Sender's user ID
598    /// * `recipient` - Recipient's user ID
599    /// * `app_id` - Application ID
600    /// * `content` - Message content
601    pub fn new(
602        sender: UserId,
603        recipient: UserId,
604        app_id: AppId,
605        content: impl Into<String>,
606    ) -> Self {
607        Self {
608            id: MessageId::new(),
609            sender,
610            recipient,
611            app_id,
612            priority: MessagePriority::default(),
613            ttl: TTL::default(),
614            hop_count: HopCount::new(),
615            timestamp: Timestamp::now(),
616            lamport_clock: LamportClock::default(),
617            content_type: ContentType::default(),
618            content: content.into(),
619            binary_content: None,
620            media_metadata: None,
621            metadata: HashMap::new(),
622            requires_ack: true,
623            reply_to_msg: None,
624            forwarded_from: None,
625            reply_context: None,
626            transport_peer_id: None,
627            wire_codec: WireCodec::Json,
628        }
629    }
630
631    /// Creates a message builder for more control over message creation.
632    pub fn builder(sender: UserId, recipient: UserId, app_id: AppId) -> MessageBuilder {
633        MessageBuilder::new(sender, recipient, app_id)
634    }
635
636    /// Increments the hop count.
637    ///
638    /// # Returns
639    ///
640    /// Returns `Ok(())` if successful, `Err` if hop count overflows.
641    pub fn increment_hop(&mut self) -> crate::Result<()> {
642        self.hop_count = self
643            .hop_count
644            .increment()
645            .ok_or(crate::Error::InvalidHopCount(u8::MAX))?;
646        Ok(())
647    }
648
649    /// Decrements the TTL.
650    ///
651    /// # Returns
652    ///
653    /// Returns `Ok(())` if successful, `Err` if TTL is exhausted.
654    pub fn decrement_ttl(&mut self) -> crate::Result<()> {
655        self.ttl = self.ttl.decrement().ok_or(crate::Error::InvalidTTL(0))?;
656        Ok(())
657    }
658
659    /// Checks if the message's TTL is exhausted.
660    pub fn is_ttl_exhausted(&self) -> bool {
661        self.ttl.is_exhausted()
662    }
663
664    /// Serializes the message to JSON.
665    pub fn to_json(&self) -> crate::Result<String> {
666        serde_json::to_string(self).map_err(|e| crate::Error::SerializationError(e.to_string()))
667    }
668
669    /// Deserializes a message from JSON.
670    pub fn from_json(json: &str) -> crate::Result<Self> {
671        serde_json::from_str(json).map_err(|e| crate::Error::DeserializationError(e.to_string()))
672    }
673
674    /// Sets the transport-verified peer identity.
675    ///
676    /// # Security
677    ///
678    /// This method must only be called by transport layer implementations
679    /// (e.g., `BleTransport::on_message_received_from`) to bind a message to
680    /// the physical peer that delivered it. The protocol layer uses this value
681    /// to reject control messages where the claimed `sender` does not match the
682    /// transport-authenticated peer.
683    ///
684    /// This method is `pub` because transport implementations live in a
685    /// separate crate (`offline-protocol-transport`). It is **not** exposed
686    /// through UniFFI bindings, and the underlying field is private +
687    /// `#[serde(skip)]`, so application code cannot set it via deserialization
688    /// or direct field access.
689    ///
690    /// **Do not call from application code or expose via FFI.**
691    ///
692    /// # Errors
693    ///
694    /// Returns an error if `peer_id` is empty, since an empty transport peer
695    /// identity cannot meaningfully authenticate a sender.
696    #[doc(hidden)]
697    pub fn set_transport_peer_id(&mut self, peer_id: String) -> crate::Result<()> {
698        if peer_id.is_empty() {
699            return Err(crate::Error::InvalidMessage(
700                "transport_peer_id must not be empty".to_string(),
701            ));
702        }
703        self.transport_peer_id = Some(peer_id);
704        Ok(())
705    }
706
707    /// Returns the transport-verified peer identity, if set.
708    pub fn transport_peer_id(&self) -> Option<&str> {
709        self.transport_peer_id.as_deref()
710    }
711
712    /// Selects the wire codec used the next time this message is serialized.
713    ///
714    /// Stamped by the transport manager from negotiated per-peer capability.
715    /// Application code should not call this: an incorrect choice can make a
716    /// message undecodable to a peer that does not support the codec.
717    #[doc(hidden)]
718    pub fn set_wire_codec(&mut self, codec: WireCodec) {
719        self.wire_codec = codec;
720    }
721
722    /// Returns the wire codec selected for this message's next serialization.
723    pub fn wire_codec(&self) -> WireCodec {
724        self.wire_codec
725    }
726
727    /// Serializes the message to binary (MessagePack-like JSON bytes).
728    pub fn to_bytes(&self) -> crate::Result<Vec<u8>> {
729        serde_json::to_vec(self).map_err(|e| crate::Error::SerializationError(e.to_string()))
730    }
731
732    /// Deserializes a message from binary.
733    pub fn from_bytes(bytes: &[u8]) -> crate::Result<Self> {
734        serde_json::from_slice(bytes).map_err(|e| crate::Error::DeserializationError(e.to_string()))
735    }
736
737    /// Serializes the message using the compact binary wire codec (v1).
738    ///
739    /// See [`crate::wire`] for the format. This is an additive alternative to
740    /// [`to_bytes`](Self::to_bytes) (JSON); the two are distinguishable by their
741    /// first byte, so a receiver can accept either without negotiation.
742    pub fn to_wire_v1_bytes(&self) -> crate::Result<Vec<u8>> {
743        let media_metadata_json =
744            match &self.media_metadata {
745                Some(m) => Some(serde_json::to_vec(m).map_err(|e| {
746                    crate::Error::SerializationError(format!("media_metadata: {e}"))
747                })?),
748                None => None,
749            };
750        let forwarded_from_json =
751            match &self.forwarded_from {
752                Some(f) => Some(serde_json::to_vec(f).map_err(|e| {
753                    crate::Error::SerializationError(format!("forwarded_from: {e}"))
754                })?),
755                None => None,
756            };
757        let mut metadata: Vec<(String, String)> = self
758            .metadata
759            .iter()
760            .map(|(k, v)| (k.clone(), v.clone()))
761            .collect();
762        // Deterministic order (a HashMap iterates nondeterministically).
763        metadata.sort_by(|a, b| a.0.cmp(&b.0));
764
765        // A long canonical-base64 content tail (an MLS envelope, typically)
766        // rides raw in the ext TLV instead of paying the 4/3 base64 inflation;
767        // the split is verified byte-exact at encode time, so decoding
768        // reconstructs the identical string. See [`crate::wire::EXT_TAG_B64_TAIL`].
769        let (content, mut ext) = match crate::wire::split_b64_tail(&self.content) {
770            Some((head, raw)) => (head.to_string(), vec![(crate::wire::EXT_TAG_B64_TAIL, raw)]),
771            None => (self.content.clone(), Vec::new()),
772        };
773
774        // The quoted-reply context rides the ext TLV as a JSON blob (like
775        // `media_metadata_json`) because the v1 DTO is frozen. Old decoders
776        // skip the tag, losing only the reply preview — the same degradation
777        // a legacy JSON receiver applies by ignoring the unknown field.
778        if let Some(rc) = &self.reply_context {
779            ext.push((
780                crate::wire::EXT_TAG_REPLY_CONTEXT,
781                serde_json::to_vec(rc)
782                    .map_err(|e| crate::Error::SerializationError(format!("reply_context: {e}")))?,
783            ));
784        }
785
786        let wire = crate::wire::WireMessageV1 {
787            id: self.id.as_bytes(),
788            sender: self.sender.as_str().to_string(),
789            recipient: self.recipient.as_str().to_string(),
790            app_id: self.app_id.as_str().to_string(),
791            priority: crate::wire::priority_to_u8(self.priority),
792            ttl: self.ttl.value(),
793            hop_count: self.hop_count.value(),
794            timestamp: self.timestamp.as_millis(),
795            lamport_clock: self.lamport_clock.value(),
796            content_type: crate::wire::content_type_to_u8(self.content_type),
797            content,
798            binary_content: self.binary_content.clone(),
799            media_metadata_json,
800            metadata,
801            requires_ack: self.requires_ack,
802            reply_to_msg: self.reply_to_msg.as_ref().map(|m| m.as_bytes()),
803            forwarded_from_json,
804            ext,
805        };
806        crate::wire::encode(&wire)
807    }
808
809    /// Deserializes a message from a compact binary wire frame (v1).
810    ///
811    /// Runs the same identifier and Lamport-clock validation as the JSON path by
812    /// routing every field through its validating constructor, so a hostile
813    /// binary frame is rejected identically to a hostile JSON one. The
814    /// transport-verified peer identity is never on the wire and is left unset.
815    pub fn from_wire_v1_bytes(data: &[u8]) -> crate::Result<Self> {
816        let wire = crate::wire::decode(data)?;
817        // Reassemble a base64 content tail carried raw in the ext TLV. Senders
818        // emit at most one tag-1 entry; only the first is honored so a hostile
819        // frame cannot splice several. Unknown tags are ignored (forward
820        // compatibility — see the tag registry on `EXT_TAG_B64_TAIL`).
821        let mut content = wire.content;
822        if let Some((_, raw)) = wire
823            .ext
824            .iter()
825            .find(|(tag, _)| *tag == crate::wire::EXT_TAG_B64_TAIL)
826        {
827            content.push_str(&crate::wire::encode_b64_tail(raw));
828        }
829        let media_metadata =
830            match wire.media_metadata_json {
831                Some(bytes) => Some(serde_json::from_slice(&bytes).map_err(|e| {
832                    crate::Error::DeserializationError(format!("media_metadata: {e}"))
833                })?),
834                None => None,
835            };
836        let forwarded_from =
837            match wire.forwarded_from_json {
838                Some(bytes) => Some(serde_json::from_slice(&bytes).map_err(|e| {
839                    crate::Error::DeserializationError(format!("forwarded_from: {e}"))
840                })?),
841                None => None,
842            };
843        // Like tag 1, only the first tag-2 entry is honored; a malformed
844        // payload rejects the frame, matching the JSON path where a malformed
845        // `reply_context` field rejects the whole message.
846        let reply_context =
847            match wire
848                .ext
849                .iter()
850                .find(|(tag, _)| *tag == crate::wire::EXT_TAG_REPLY_CONTEXT)
851            {
852                Some((_, bytes)) => Some(serde_json::from_slice(bytes).map_err(|e| {
853                    crate::Error::DeserializationError(format!("reply_context: {e}"))
854                })?),
855                None => None,
856            };
857        Ok(Self {
858            id: MessageId::from_bytes(wire.id),
859            sender: UserId::new(wire.sender)?,
860            recipient: UserId::new(wire.recipient)?,
861            app_id: AppId::new(wire.app_id)?,
862            priority: crate::wire::priority_from_u8(wire.priority),
863            ttl: TTL::from_value(wire.ttl),
864            hop_count: HopCount::from_value(wire.hop_count),
865            timestamp: Timestamp::from_millis(wire.timestamp),
866            lamport_clock: LamportClock::from_value(wire.lamport_clock),
867            content_type: crate::wire::content_type_from_u8(wire.content_type),
868            content,
869            binary_content: wire.binary_content,
870            media_metadata,
871            metadata: wire.metadata.into_iter().collect(),
872            requires_ack: wire.requires_ack,
873            reply_to_msg: wire.reply_to_msg.map(MessageId::from_bytes),
874            forwarded_from,
875            reply_context,
876            transport_peer_id: None,
877            wire_codec: WireCodec::Json,
878        })
879    }
880}
881
882/// Builder for creating messages with custom settings.
883pub struct MessageBuilder {
884    sender: UserId,
885    recipient: UserId,
886    app_id: AppId,
887    content_type: ContentType,
888    content: String,
889    media_metadata: Option<MediaMetadata>,
890    priority: MessagePriority,
891    ttl: TTL,
892    lamport_clock: LamportClock,
893    metadata: HashMap<String, String>,
894    requires_ack: bool,
895    reply_to_msg: Option<MessageId>,
896    forwarded_from: Option<ForwardInfo>,
897    reply_context: Option<ReplyContext>,
898}
899
900impl MessageBuilder {
901    /// Creates a new message builder.
902    pub fn new(sender: UserId, recipient: UserId, app_id: AppId) -> Self {
903        Self {
904            sender,
905            recipient,
906            app_id,
907            content_type: ContentType::default(),
908            content: String::new(),
909            media_metadata: None,
910            priority: MessagePriority::default(),
911            ttl: TTL::default(),
912            lamport_clock: LamportClock::default(),
913            metadata: HashMap::new(),
914            requires_ack: true,
915            reply_to_msg: None,
916            forwarded_from: None,
917            reply_context: None,
918        }
919    }
920
921    /// Sets the content type.
922    pub fn content_type(mut self, content_type: ContentType) -> Self {
923        self.content_type = content_type;
924        self
925    }
926
927    /// Sets the message content.
928    pub fn content(mut self, content: impl Into<String>) -> Self {
929        self.content = content.into();
930        self
931    }
932
933    /// Sets the media metadata.
934    pub fn media_metadata(mut self, meta: MediaMetadata) -> Self {
935        self.media_metadata = Some(meta);
936        self
937    }
938
939    /// Sets the message priority.
940    pub fn priority(mut self, priority: MessagePriority) -> Self {
941        self.priority = priority;
942        self
943    }
944
945    /// Sets the TTL.
946    pub fn ttl(mut self, ttl: TTL) -> Self {
947        self.ttl = ttl;
948        self
949    }
950
951    /// Adds metadata to the message.
952    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
953        self.metadata.insert(key.into(), value.into());
954        self
955    }
956
957    /// Sets whether the message requires an ACK.
958    pub fn requires_ack(mut self, requires_ack: bool) -> Self {
959        self.requires_ack = requires_ack;
960        self
961    }
962
963    /// Sets the message this is replying to.
964    pub fn reply_to_msg(mut self, reply_to: MessageId) -> Self {
965        self.reply_to_msg = Some(reply_to);
966        self
967    }
968
969    /// Sets forwarding attribution on the message.
970    pub fn forwarded_from(mut self, info: ForwardInfo) -> Self {
971        self.forwarded_from = Some(info);
972        self
973    }
974
975    /// Sets the quoted-reply context on the message.
976    pub fn reply_context(mut self, ctx: ReplyContext) -> Self {
977        self.reply_context = Some(ctx);
978        self
979    }
980
981    /// Sets the Lamport clock value for this message.
982    pub fn lamport_clock(mut self, clock: LamportClock) -> Self {
983        self.lamport_clock = clock;
984        self
985    }
986
987    /// Builds the message.
988    pub fn build(self) -> Message {
989        Message {
990            id: MessageId::new(),
991            sender: self.sender,
992            recipient: self.recipient,
993            app_id: self.app_id,
994            priority: self.priority,
995            ttl: self.ttl,
996            hop_count: HopCount::new(),
997            timestamp: Timestamp::now(),
998            lamport_clock: self.lamport_clock,
999            content_type: self.content_type,
1000            content: self.content,
1001            binary_content: None,
1002            media_metadata: self.media_metadata,
1003            metadata: self.metadata,
1004            requires_ack: self.requires_ack,
1005            reply_to_msg: self.reply_to_msg,
1006            forwarded_from: self.forwarded_from,
1007            reply_context: self.reply_context,
1008            transport_peer_id: None,
1009            wire_codec: WireCodec::Json,
1010        }
1011    }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017
1018    fn create_test_message() -> Message {
1019        Message::new(
1020            UserId::new("alice").unwrap(),
1021            UserId::new("bob").unwrap(),
1022            AppId::new("test-app").unwrap(),
1023            "Hello, Bob!",
1024        )
1025    }
1026
1027    #[test]
1028    fn test_message_creation() {
1029        let msg = create_test_message();
1030        assert_eq!(msg.sender.as_str(), "alice");
1031        assert_eq!(msg.recipient.as_str(), "bob");
1032        assert_eq!(msg.content, "Hello, Bob!");
1033        assert_eq!(msg.priority, MessagePriority::Medium);
1034        assert_eq!(msg.hop_count.value(), 0);
1035    }
1036
1037    #[test]
1038    fn test_message_builder() {
1039        let msg = Message::builder(
1040            UserId::new("alice").unwrap(),
1041            UserId::new("bob").unwrap(),
1042            AppId::new("test-app").unwrap(),
1043        )
1044        .content("Test message")
1045        .priority(MessagePriority::High)
1046        .metadata("key1", "value1")
1047        .requires_ack(false)
1048        .build();
1049
1050        assert_eq!(msg.content, "Test message");
1051        assert_eq!(msg.priority, MessagePriority::High);
1052        assert_eq!(msg.metadata.get("key1").unwrap(), "value1");
1053        assert!(!msg.requires_ack);
1054    }
1055
1056    #[test]
1057    fn reply_context_json_round_trip_and_default() {
1058        let mut msg = create_test_message();
1059        msg.reply_context = Some(ReplyContext {
1060            sender: UserId::new("carol").unwrap(),
1061            text: "quoted".into(),
1062            timestamp: Some(Timestamp::from_millis(1_700_000_000_000)),
1063            reply_media_label: None,
1064            reply_content_type: Some("text".into()),
1065        });
1066        let decoded = Message::from_bytes(&msg.to_bytes().unwrap()).unwrap();
1067        assert_eq!(decoded.reply_context, msg.reply_context);
1068
1069        // Absent on the wire → defaults to None (old sender, new receiver).
1070        let plain = create_test_message();
1071        let json = serde_json::to_string(&plain).unwrap();
1072        assert!(!json.contains("reply_context"));
1073        let decoded = Message::from_bytes(json.as_bytes()).unwrap();
1074        assert!(decoded.reply_context.is_none());
1075    }
1076
1077    #[test]
1078    fn media_metadata_without_secrets_clears_only_key_material() {
1079        let meta = MediaMetadata {
1080            mime_type: "image/jpeg".into(),
1081            file_name: "x.jpg".into(),
1082            file_size: 10,
1083            duration_ms: None,
1084            width: Some(1),
1085            height: Some(1),
1086            thumbnail_base64: None,
1087            media_id: Some("m1".into()),
1088            download_url: Some("https://cdn.example/m1".into()),
1089            thumbnail_url: Some("https://cdn.example/m1-t".into()),
1090            encryption_key: Some("a2V5".into()),
1091            iv: Some("aXY=".into()),
1092            ciphertext_hash: Some("aGFzaA==".into()),
1093            sticker_provider: Some("prov".into()),
1094            sticker_remote_id: Some("rid".into()),
1095            sticker_kind: Some("static".into()),
1096        };
1097        assert!(meta.has_secrets());
1098
1099        let clean = meta.without_secrets();
1100        assert!(!clean.has_secrets());
1101        assert!(clean.encryption_key.is_none());
1102        assert!(clean.iv.is_none());
1103        // Everything that isn't key material survives.
1104        assert_eq!(clean.media_id, meta.media_id);
1105        assert_eq!(clean.download_url, meta.download_url);
1106        assert_eq!(clean.thumbnail_url, meta.thumbnail_url);
1107        assert_eq!(clean.ciphertext_hash, meta.ciphertext_hash);
1108        assert_eq!(clean.sticker_provider, meta.sticker_provider);
1109        assert_eq!(clean.sticker_remote_id, meta.sticker_remote_id);
1110        assert_eq!(clean.sticker_kind, meta.sticker_kind);
1111        assert_eq!(clean.file_name, meta.file_name);
1112    }
1113
1114    #[test]
1115    fn message_json_ignores_unknown_fields() {
1116        // A legacy receiver's tolerance for fields it doesn't know is the
1117        // compat story for `reply_context` (new sender, old receiver); prove
1118        // Message keeps that tolerance.
1119        let mut json = serde_json::to_value(create_test_message()).unwrap();
1120        json["some_future_field"] = serde_json::Value::String("x".into());
1121        let bytes = serde_json::to_vec(&json).unwrap();
1122        assert!(Message::from_bytes(&bytes).is_ok());
1123    }
1124
1125    #[test]
1126    fn test_message_hop_operations() {
1127        let mut msg = create_test_message();
1128        assert_eq!(msg.hop_count.value(), 0);
1129
1130        msg.increment_hop().unwrap();
1131        assert_eq!(msg.hop_count.value(), 1);
1132    }
1133
1134    #[test]
1135    fn test_message_ttl_operations() {
1136        let mut msg = create_test_message();
1137        let initial_ttl = msg.ttl.value();
1138
1139        msg.decrement_ttl().unwrap();
1140        assert_eq!(msg.ttl.value(), initial_ttl - 1);
1141    }
1142
1143    #[test]
1144    fn test_message_serialization() {
1145        let msg = create_test_message();
1146
1147        // JSON serialization
1148        let json = msg.to_json().unwrap();
1149        let deserialized = Message::from_json(&json).unwrap();
1150        assert_eq!(msg.id, deserialized.id);
1151        assert_eq!(msg.sender, deserialized.sender);
1152        assert_eq!(msg.content, deserialized.content);
1153
1154        // Binary serialization
1155        let bytes = msg.to_bytes().unwrap();
1156        let deserialized = Message::from_bytes(&bytes).unwrap();
1157        assert_eq!(msg.id, deserialized.id);
1158    }
1159
1160    #[test]
1161    fn test_message_priority_score() {
1162        assert_eq!(MessagePriority::Low.score(), 1);
1163        assert_eq!(MessagePriority::Medium.score(), 2);
1164        assert_eq!(MessagePriority::High.score(), 3);
1165        assert_eq!(MessagePriority::Critical.score(), 4);
1166    }
1167
1168    #[test]
1169    fn test_forward_info_serialization() {
1170        use crate::types::Timestamp;
1171
1172        let info = ForwardInfo {
1173            original_sender: UserId::new("alice").unwrap(),
1174            original_message_id: MessageId::new(),
1175            original_timestamp: Timestamp::now(),
1176            forward_count: 1,
1177        };
1178
1179        let msg = Message::builder(
1180            UserId::new("bob").unwrap(),
1181            UserId::new("charlie").unwrap(),
1182            AppId::new("test-app").unwrap(),
1183        )
1184        .content("Forwarded message")
1185        .forwarded_from(info.clone())
1186        .build();
1187
1188        assert!(msg.forwarded_from.is_some());
1189        let fwd = msg.forwarded_from.as_ref().unwrap();
1190        assert_eq!(fwd.original_sender.as_str(), "alice");
1191        assert_eq!(fwd.forward_count, 1);
1192
1193        // Round-trip through JSON
1194        let json = msg.to_json().unwrap();
1195        let deserialized = Message::from_json(&json).unwrap();
1196        let fwd2 = deserialized.forwarded_from.as_ref().unwrap();
1197        assert_eq!(fwd2.original_sender.as_str(), "alice");
1198        assert_eq!(fwd2.forward_count, 1);
1199    }
1200
1201    #[test]
1202    fn test_backward_compat_no_forwarded_from() {
1203        // Old messages without forwarded_from should deserialize with None
1204        let msg = create_test_message();
1205        let json = msg.to_json().unwrap();
1206
1207        // Verify forwarded_from is not in the JSON (skip_serializing_if = None)
1208        assert!(!json.contains("forwarded_from"));
1209
1210        let deserialized = Message::from_json(&json).unwrap();
1211        assert!(deserialized.forwarded_from.is_none());
1212    }
1213
1214    #[test]
1215    fn test_forward_chain_increment() {
1216        // Simulate: Alice sends original → Bob forwards (count=1) → Charlie forwards (count=2)
1217        let original_msg = Message::builder(
1218            UserId::new("alice").unwrap(),
1219            UserId::new("bob").unwrap(),
1220            AppId::new("test-app").unwrap(),
1221        )
1222        .content("Hello from Alice")
1223        .build();
1224
1225        // Bob forwards to Charlie — ForwardInfo::from_message creates attribution
1226        let first_forward = ForwardInfo::from_message(&original_msg);
1227        assert_eq!(first_forward.original_sender.as_str(), "alice");
1228        assert_eq!(first_forward.original_message_id, original_msg.id);
1229        assert_eq!(first_forward.forward_count, 1);
1230
1231        // Charlie forwards to Dave — should carry original_sender and increment count
1232        let forwarded_msg = Message::builder(
1233            UserId::new("bob").unwrap(),
1234            UserId::new("charlie").unwrap(),
1235            AppId::new("test-app").unwrap(),
1236        )
1237        .content("Hello from Alice")
1238        .forwarded_from(first_forward)
1239        .build();
1240
1241        let second_forward = ForwardInfo::from_message(&forwarded_msg);
1242        assert_eq!(second_forward.original_sender.as_str(), "alice");
1243        assert_eq!(second_forward.forward_count, 2);
1244        assert_eq!(second_forward.original_message_id, original_msg.id);
1245    }
1246
1247    #[test]
1248    fn test_forward_count_saturates_at_max() {
1249        use crate::types::Timestamp;
1250
1251        let saturated = ForwardInfo {
1252            original_sender: UserId::new("alice").unwrap(),
1253            original_message_id: MessageId::new(),
1254            original_timestamp: Timestamp::now(),
1255            forward_count: u32::MAX,
1256        };
1257
1258        let msg = Message::builder(
1259            UserId::new("bob").unwrap(),
1260            UserId::new("charlie").unwrap(),
1261            AppId::new("test-app").unwrap(),
1262        )
1263        .content("Hello")
1264        .forwarded_from(saturated)
1265        .build();
1266
1267        let next = ForwardInfo::from_message(&msg);
1268        assert_eq!(next.forward_count, u32::MAX);
1269    }
1270
1271    #[test]
1272    fn content_type_json_round_trips_every_variant() {
1273        // Iterating `ALL` (compile-time-pinned complete) rather than a
1274        // hand-written list is what makes this test able to catch a variant
1275        // added without a `ContentType::parse` arm — the one string mapping
1276        // rustc cannot check for itself.
1277        for ct in ContentType::ALL {
1278            let json = serde_json::to_string(&ct).unwrap();
1279            assert_eq!(json, format!("\"{ct}\""));
1280            assert_eq!(serde_json::from_str::<ContentType>(&json).unwrap(), ct);
1281            assert_eq!(ContentType::parse(ct.as_wire_str()), ct);
1282        }
1283    }
1284
1285    #[test]
1286    fn content_type_round_trips_in_non_self_describing_formats() {
1287        // Both serde directions speak strings, so a format that carries no
1288        // field/variant names round-trips too. A derived `Serialize` would
1289        // write the variant index here, which the tolerant string-based
1290        // `Deserialize` would read back as "" and degrade to `File` —
1291        // silently turning `Text` into `File`.
1292        for ct in ContentType::ALL {
1293            let bytes = postcard::to_allocvec(&ct).unwrap();
1294            assert_eq!(postcard::from_bytes::<ContentType>(&bytes).unwrap(), ct);
1295        }
1296    }
1297
1298    #[test]
1299    fn unknown_content_type_degrades_to_file_on_json_decode() {
1300        // A variant added by a newer sender must not fail this receiver's
1301        // decode: all three paths (JSON, `parse`, binary u8) share the
1302        // `File` fallback.
1303        assert_eq!(
1304            serde_json::from_str::<ContentType>("\"some_future_type\"").unwrap(),
1305            ContentType::File
1306        );
1307        // A non-string is malformed data, not additive evolution — still an
1308        // error.
1309        assert!(serde_json::from_str::<ContentType>("7").is_err());
1310    }
1311
1312    #[test]
1313    fn message_with_unknown_content_type_still_decodes() {
1314        let msg = create_test_message();
1315        let mut value = serde_json::to_value(&msg).unwrap();
1316        value["content_type"] = serde_json::Value::String("some_future_type".to_string());
1317        let decoded: Message = serde_json::from_value(value).unwrap();
1318        assert_eq!(decoded.content_type, ContentType::File);
1319        assert_eq!(decoded.content, msg.content);
1320        assert_eq!(decoded.id, msg.id);
1321    }
1322}