Skip to main content

offline_protocol_core/
message.rs

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