Skip to main content

river_core/room_state/
content.rs

1//! Message content types for client-side interpretation.
2//!
3//! The contract treats message content as opaque bytes with type/version tags.
4//! This module defines the client-side content types that are encoded into those bytes.
5//!
6//! # Extensibility
7//!
8//! - New content types: Add new `CONTENT_TYPE_*` constant, no contract change needed
9//! - New action types: Add new `ACTION_TYPE_*` constant, no contract change needed
10//! - New fields on existing types: Just add them (old clients ignore unknown fields)
11//! - Breaking format changes: Bump the version constant for that type
12
13use crate::room_state::message::MessageId;
14use serde::{Deserialize, Serialize};
15
16/// Content type constants
17pub const CONTENT_TYPE_TEXT: u32 = 1;
18pub const CONTENT_TYPE_ACTION: u32 = 2;
19pub const CONTENT_TYPE_REPLY: u32 = 3;
20pub const CONTENT_TYPE_EVENT: u32 = 4;
21// Future: CONTENT_TYPE_BLOB = 5, CONTENT_TYPE_POLL = 6, etc.
22
23/// Current version for text content
24pub const TEXT_CONTENT_VERSION: u32 = 1;
25
26/// Current version for action content
27pub const ACTION_CONTENT_VERSION: u32 = 1;
28
29/// Current version for reply content
30pub const REPLY_CONTENT_VERSION: u32 = 1;
31
32/// Current version for event content
33pub const EVENT_CONTENT_VERSION: u32 = 1;
34
35/// Event type constants
36pub const EVENT_TYPE_JOIN: u32 = 1;
37// Future: EVENT_TYPE_LEAVE = 2, etc.
38
39/// Action type constants
40pub const ACTION_TYPE_EDIT: u32 = 1;
41pub const ACTION_TYPE_DELETE: u32 = 2;
42pub const ACTION_TYPE_REACTION: u32 = 3;
43pub const ACTION_TYPE_REMOVE_REACTION: u32 = 4;
44// Future: ACTION_TYPE_PIN = 5, ACTION_TYPE_REPLY = 6, etc.
45
46/// Text message content (content_type = 1)
47#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
48pub struct TextContentV1 {
49    pub text: String,
50}
51
52impl TextContentV1 {
53    pub fn new(text: String) -> Self {
54        Self { text }
55    }
56
57    /// Encode to CBOR bytes
58    pub fn encode(&self) -> Vec<u8> {
59        encode_cbor(self)
60    }
61
62    /// Decode from CBOR bytes
63    pub fn decode(data: &[u8]) -> Result<Self, String> {
64        decode_cbor(data, "TextContentV1")
65    }
66}
67
68/// Encode a value to CBOR bytes
69fn encode_cbor<T: Serialize>(value: &T) -> Vec<u8> {
70    let mut data = Vec::new();
71    ciborium::into_writer(value, &mut data).expect("CBOR serialization should not fail");
72    data
73}
74
75/// Decode a value from CBOR bytes
76fn decode_cbor<T: serde::de::DeserializeOwned>(data: &[u8], type_name: &str) -> Result<T, String> {
77    ciborium::from_reader(data).map_err(|e| format!("Failed to decode {}: {}", type_name, e))
78}
79
80/// Serde helper: encode [`ActionContentV1::payload`] as a CBOR **byte string**
81/// while still decoding the legacy **array-of-integers** form.
82///
83/// serde has no distinct byte-string type in the derive path, so a bare
84/// `Vec<u8>` goes through `serialize_seq` and ciborium writes a CBOR array of
85/// integers. Every byte >= 0x18 then costs 2 bytes on the wire, and all
86/// printable ASCII is >= 0x20 — so an edit cost ~2.1 bytes per character while
87/// a plain `TextContentV1 { text: String }` message cost ~1.01 (a CBOR text
88/// string). Against the default `max_message_size` of 1000 that capped edits at
89/// ~467 characters while sends allowed ~991, i.e. a message could be sent and
90/// then never edited (freenet/river#443).
91///
92/// Serializing as a byte string brings edits to ~1.05 bytes per character.
93///
94/// `deserialize` accepts BOTH encodings, which is required rather than
95/// cosmetic: rooms created before this change hold action payloads in the
96/// array form, and contract migration re-PUTs that existing state into the new
97/// contract. Without the legacy arm every pre-existing edit and reaction would
98/// silently stop rendering (`ActionContentV1::decode` -> `Err` -> the action is
99/// skipped by `rebuild_actions_state_with_decrypted`).
100///
101/// `deserialize_any` is sound here because this type is only ever serialized
102/// with ciborium (see `encode_cbor` / `decode_cbor` above) and CBOR is
103/// self-describing. Do NOT reuse this helper for a type that may be handled by
104/// a non-self-describing format such as bincode.
105///
106/// One behavioural note: `deserialize_any` is the only ciborium entry point
107/// that does NOT skip a `Header::Tag` — it routes tags to `visit_enum`,
108/// whereas the derived `deserialize_seq` path transparently unwrapped them. So
109/// this helper is marginally STRICTER than what it replaced. Nothing in River
110/// emits CBOR tags, so no stored payload is affected.
111mod payload_bytes {
112    use serde::de::{Error as _, SeqAccess, Visitor};
113    use serde::{Deserializer, Serializer};
114    use std::fmt;
115
116    pub fn serialize<S: Serializer>(payload: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
117        serializer.serialize_bytes(payload)
118    }
119
120    struct BytesOrLegacySeq;
121
122    impl<'de> Visitor<'de> for BytesOrLegacySeq {
123        type Value = Vec<u8>;
124
125        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
126            f.write_str("a CBOR byte string, or a legacy array of byte values")
127        }
128
129        fn visit_bytes<E: serde::de::Error>(self, v: &[u8]) -> Result<Self::Value, E> {
130            Ok(v.to_vec())
131        }
132
133        fn visit_byte_buf<E: serde::de::Error>(self, v: Vec<u8>) -> Result<Self::Value, E> {
134            Ok(v)
135        }
136
137        /// Legacy form: a CBOR array of integers, one per byte.
138        fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
139            // NEVER pre-allocate from `size_hint()` unbounded. It is the
140            // DECLARED length from the attacker-supplied CBOR array header,
141            // which ciborium returns verbatim without checking it against the
142            // remaining input. This decode runs inside the room contract on
143            // untrusted peer data, so a ~50-byte message carrying a header
144            // like `0x9B FF..FF` (2^64-1 elements) would otherwise become a
145            // capacity-overflow panic or a multi-gigabyte allocation. serde's
146            // derived `Vec<u8>` impl bounds this with `size_hint::cautious`;
147            // this hand-rolled visitor must re-establish that bound. The Vec
148            // still grows as needed, so a legitimately larger payload is
149            // unaffected. Pinned by
150            // `legacy_payload_with_lying_length_header_errors_not_panics`.
151            const MAX_PREALLOC: usize = 4096;
152            let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0).min(MAX_PREALLOC));
153            // Read as u16 purely for a clearer error message. `next_element::<u8>()`
154            // would ALSO be correct — serde's u8 visitor range-checks and errors
155            // on anything above 255, it does not truncate. Do not "harden" this
156            // against a truncation bug that does not exist.
157            while let Some(byte) = seq.next_element::<u16>()? {
158                if byte > u8::MAX as u16 {
159                    // A static message keeps `core::fmt` machinery out of the
160                    // contract WASM.
161                    return Err(A::Error::custom(
162                        "action payload element is not a byte value",
163                    ));
164                }
165                out.push(byte as u8);
166            }
167            Ok(out)
168        }
169    }
170
171    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
172        deserializer.deserialize_any(BytesOrLegacySeq)
173    }
174}
175
176/// Action message content (content_type = 2)
177#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
178pub struct ActionContentV1 {
179    /// Type of action (ACTION_TYPE_* constants)
180    pub action_type: u32,
181    /// Target message ID for the action
182    pub target: MessageId,
183    /// Action-specific payload (CBOR-encoded).
184    ///
185    /// Encoded on the wire as a CBOR byte string; the legacy array-of-integers
186    /// form is still accepted on decode. See the `payload_bytes` module and
187    /// freenet/river#443 — do NOT drop the `serde(with = ...)` attribute, it is
188    /// what keeps an edit from costing ~2 bytes per character.
189    #[serde(with = "payload_bytes")]
190    pub payload: Vec<u8>,
191}
192
193impl ActionContentV1 {
194    /// Create an edit action
195    pub fn edit(target: MessageId, new_text: String) -> Self {
196        Self {
197            action_type: ACTION_TYPE_EDIT,
198            target,
199            payload: encode_cbor(&EditPayload { new_text }),
200        }
201    }
202
203    /// Create a delete action
204    pub fn delete(target: MessageId) -> Self {
205        Self {
206            action_type: ACTION_TYPE_DELETE,
207            target,
208            payload: Vec::new(),
209        }
210    }
211
212    /// Create a reaction action
213    pub fn reaction(target: MessageId, emoji: String) -> Self {
214        Self {
215            action_type: ACTION_TYPE_REACTION,
216            target,
217            payload: encode_cbor(&ReactionPayload { emoji }),
218        }
219    }
220
221    /// Create a remove reaction action
222    pub fn remove_reaction(target: MessageId, emoji: String) -> Self {
223        Self {
224            action_type: ACTION_TYPE_REMOVE_REACTION,
225            target,
226            payload: encode_cbor(&ReactionPayload { emoji }),
227        }
228    }
229
230    /// Encode to CBOR bytes
231    pub fn encode(&self) -> Vec<u8> {
232        encode_cbor(self)
233    }
234
235    /// Decode from CBOR bytes
236    pub fn decode(data: &[u8]) -> Result<Self, String> {
237        decode_cbor(data, "ActionContentV1")
238    }
239
240    /// Get the edit payload if this is an edit action
241    pub fn edit_payload(&self) -> Option<EditPayload> {
242        if self.action_type == ACTION_TYPE_EDIT {
243            ciborium::from_reader(&self.payload[..]).ok()
244        } else {
245            None
246        }
247    }
248
249    /// Get the reaction payload if this is a reaction or remove_reaction action
250    pub fn reaction_payload(&self) -> Option<ReactionPayload> {
251        if self.action_type == ACTION_TYPE_REACTION
252            || self.action_type == ACTION_TYPE_REMOVE_REACTION
253        {
254            ciborium::from_reader(&self.payload[..]).ok()
255        } else {
256            None
257        }
258    }
259}
260
261/// Payload for edit actions
262#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
263pub struct EditPayload {
264    pub new_text: String,
265}
266
267/// Payload for reaction actions
268#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
269pub struct ReactionPayload {
270    pub emoji: String,
271}
272
273/// Reply message content (content_type = 3)
274///
275/// A reply references a target message by `target_message_id`. It ALSO carries
276/// a snapshot of that message's author name and content — which no client
277/// renders any more, and which should be removed (freenet/river#482).
278///
279/// The snapshot was intended to keep a quote meaningful after its target aged
280/// out of the recent-message window. But it is written and signed by the
281/// REPLIER and validated by nothing here, so a member can name any author and
282/// quote any text: it is a fabricate-a-quote-from-anyone surface, exploited in
283/// production against the Official room. Clients therefore resolve the quote
284/// against live room state and render nothing when the target cannot be re-read
285/// (`resolve_reply_strip` in `ui/src/components/conversation.rs`,
286/// `reply_context_display_with_secrets` in `cli/src/api.rs`) — i.e. they
287/// deliberately do NOT trust the snapshot in the one case it existed for.
288///
289/// Do not add a new consumer of these two fields. Removing them re-keys the
290/// room contract, so it needs `cargo make add-room-contract-migration` before
291/// the WASM changes; see #482 for the full remediation.
292#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
293pub struct ReplyContentV1 {
294    pub text: String,
295    pub target_message_id: MessageId,
296    /// Replier-supplied, unvalidated. Not rendered by any client; see #482.
297    pub target_author_name: String,
298    /// Replier-supplied, unvalidated snapshot of the target's content
299    /// (~100 chars). Not rendered by any client; see #482.
300    pub target_content_preview: String,
301}
302
303impl ReplyContentV1 {
304    pub fn new(
305        text: String,
306        target_message_id: MessageId,
307        target_author_name: String,
308        target_content_preview: String,
309    ) -> Self {
310        Self {
311            text,
312            target_message_id,
313            target_author_name,
314            target_content_preview,
315        }
316    }
317
318    pub fn encode(&self) -> Vec<u8> {
319        encode_cbor(self)
320    }
321
322    pub fn decode(data: &[u8]) -> Result<Self, String> {
323        decode_cbor(data, "ReplyContentV1")
324    }
325}
326
327/// Event message content (content_type = 4)
328///
329/// Represents room events like joins and leaves. These are authored by the
330/// member performing the action and count as messages for pruning purposes.
331#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
332pub struct EventContentV1 {
333    pub event_type: u32,
334}
335
336impl EventContentV1 {
337    pub fn join() -> Self {
338        Self {
339            event_type: EVENT_TYPE_JOIN,
340        }
341    }
342
343    pub fn encode(&self) -> Vec<u8> {
344        encode_cbor(self)
345    }
346
347    pub fn decode(data: &[u8]) -> Result<Self, String> {
348        decode_cbor(data, "EventContentV1")
349    }
350}
351
352/// Decoded message content for client-side processing
353#[derive(Clone, PartialEq, Debug)]
354pub enum DecodedContent {
355    /// Text message
356    Text(TextContentV1),
357    /// Action on another message
358    Action(ActionContentV1),
359    /// Reply to another message
360    Reply(ReplyContentV1),
361    /// Room event (join, leave, etc.)
362    Event(EventContentV1),
363    /// Unknown content type - preserved for round-tripping but displayed as placeholder
364    Unknown {
365        content_type: u32,
366        content_version: u32,
367    },
368}
369
370impl DecodedContent {
371    /// Check if this is an action
372    pub fn is_action(&self) -> bool {
373        matches!(self, Self::Action(_))
374    }
375
376    /// Check if this is an event
377    pub fn is_event(&self) -> bool {
378        matches!(self, Self::Event(_))
379    }
380
381    /// Get the target message ID if this is an action
382    pub fn target_id(&self) -> Option<&MessageId> {
383        match self {
384            Self::Action(action) => Some(&action.target),
385            _ => None,
386        }
387    }
388
389    /// Get the text content if this is a text or reply message
390    pub fn as_text(&self) -> Option<&str> {
391        match self {
392            Self::Text(text) => Some(&text.text),
393            Self::Reply(reply) => Some(&reply.text),
394            _ => None,
395        }
396    }
397
398    /// Get a display string for this content
399    pub fn to_display_string(&self) -> String {
400        match self {
401            Self::Text(text) => text.text.clone(),
402            Self::Reply(reply) => reply.text.clone(),
403            Self::Action(action) => match action.action_type {
404                ACTION_TYPE_EDIT => format!("[Edit of message {}]", action.target),
405                ACTION_TYPE_DELETE => format!("[Delete of message {}]", action.target),
406                ACTION_TYPE_REACTION => {
407                    let emoji = action
408                        .reaction_payload()
409                        .map(|p| p.emoji)
410                        .unwrap_or_else(|| "?".to_string());
411                    format!("[Reaction {} to {}]", emoji, action.target)
412                }
413                ACTION_TYPE_REMOVE_REACTION => {
414                    let emoji = action
415                        .reaction_payload()
416                        .map(|p| p.emoji)
417                        .unwrap_or_else(|| "?".to_string());
418                    format!("[Remove reaction {} from {}]", emoji, action.target)
419                }
420                _ => format!(
421                    "[Unknown action type {} on {}]",
422                    action.action_type, action.target
423                ),
424            },
425            Self::Event(event) => match event.event_type {
426                EVENT_TYPE_JOIN => "joined the room".to_string(),
427                _ => format!("[Unknown event type {}]", event.event_type),
428            },
429            Self::Unknown {
430                content_type,
431                content_version,
432            } => format!(
433                "[Unsupported message type {}.{} - please upgrade]",
434                content_type, content_version
435            ),
436        }
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use freenet_scaffold::util::fast_hash;
444
445    fn test_message_id() -> MessageId {
446        MessageId(fast_hash(&[1, 2, 3, 4]))
447    }
448
449    #[test]
450    fn test_text_content_roundtrip() {
451        let content = TextContentV1::new("Hello, world!".to_string());
452        let encoded = content.encode();
453        let decoded = TextContentV1::decode(&encoded).unwrap();
454        assert_eq!(content, decoded);
455    }
456
457    /// Mirror of [`ActionContentV1`] as it was encoded BEFORE freenet/river#443
458    /// (bare `Vec<u8>` -> CBOR array of integers). Stands in for both an
459    /// existing room's stored actions and a pre-#443 client on the wire.
460    #[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
461    struct LegacyActionContentV1 {
462        action_type: u32,
463        target: MessageId,
464        payload: Vec<u8>,
465    }
466
467    /// The migration-critical direction: rooms created before #443 hold action
468    /// payloads as a CBOR array of integers, and contract migration re-PUTs that
469    /// state into the new contract. If this breaks, every pre-existing edit and
470    /// reaction silently stops rendering.
471    #[test]
472    fn legacy_array_payload_still_decodes() {
473        let action = ActionContentV1::edit(test_message_id(), "Edited text".to_string());
474        let legacy = LegacyActionContentV1 {
475            action_type: action.action_type,
476            target: action.target.clone(),
477            payload: action.payload.clone(),
478        };
479        let legacy_bytes = encode_cbor(&legacy);
480
481        // Pin EXPLICITLY that the fixture really is array-encoded. Without
482        // this the test silently degenerates into a duplicate of
483        // `test_edit_action_roundtrip` if the mirror ever stops producing the
484        // legacy shape, and would no longer exercise `visit_seq` at all.
485        let as_value: ciborium::value::Value =
486            ciborium::from_reader(&legacy_bytes[..]).expect("decode as generic CBOR");
487        let payload_field = as_value
488            .as_map()
489            .expect("a CBOR map")
490            .iter()
491            .find(|(k, _)| k.as_text() == Some("payload"))
492            .map(|(_, v)| v)
493            .expect("a payload field");
494        assert!(
495            payload_field.is_array(),
496            "the legacy fixture must be a CBOR array of integers, got {payload_field:?}"
497        );
498
499        let decoded = ActionContentV1::decode(&legacy_bytes)
500            .expect("legacy array-encoded payload must still decode");
501        assert_eq!(decoded, action, "legacy decode must be lossless");
502        assert_eq!(
503            decoded.edit_payload().expect("edit payload").new_text,
504            "Edited text",
505            "the edited text must survive a legacy-format decode"
506        );
507    }
508
509    /// Build legacy (array-encoded) bytes with `payload` replaced by a raw
510    /// CBOR fragment. `payload` is the last declared field, so its encoding is
511    /// last in the output and can be swapped wholesale.
512    fn legacy_bytes_with_raw_payload(raw_payload: &[u8]) -> Vec<u8> {
513        let mut bytes = encode_cbor(&LegacyActionContentV1 {
514            action_type: ACTION_TYPE_EDIT,
515            target: test_message_id(),
516            payload: Vec::new(),
517        });
518        assert_eq!(
519            bytes.pop(),
520            Some(0x80),
521            "expected a trailing empty CBOR array for the empty payload"
522        );
523        bytes.extend_from_slice(raw_payload);
524        bytes
525    }
526
527    /// A crafted CBOR array header can declare far more elements than the
528    /// input actually contains, and ciborium's `size_hint` returns that
529    /// declared length verbatim without checking it against the remaining
530    /// bytes. Pre-allocating from it unbounded lets ANY peer turn a ~50-byte
531    /// message into a multi-gigabyte allocation or a capacity-overflow panic
532    /// inside the room contract, the UI, and riverctl. serde's derived
533    /// `Vec<u8>` impl caps this via `size_hint::cautious`; a hand-rolled
534    /// visitor has to re-establish that bound.
535    ///
536    /// The three vectors below discriminate on DIFFERENT targets — keep all of
537    /// them, none is redundant:
538    /// - `0x9B FF..FF` (2^64-1): the vector that reproduces on x86_64, where
539    ///   CI runs. It panicked with "capacity overflow" before the clamp. On
540    ///   wasm32 `usize::try_from` rejects it first, so it proves nothing there.
541    /// - `0x9A FF FF FF FF` (2^32-1): the vector that matters for the SHIPPING
542    ///   target. On wasm32 (room contract + River UI) `usize` is 32-bit, so
543    ///   this is a valid length and an unclamped `with_capacity` traps on
544    ///   `memory.grow`.
545    /// - `0x9A 00 0F 42 40` (1,000,000): a moderate lie that would reserve
546    ///   1 MB. Errors either way; it pins that a truncated array is a clean
547    ///   decode error rather than a partial read.
548    #[test]
549    fn legacy_payload_with_lying_length_header_errors_not_panics() {
550        for (label, header) in [
551            (
552                "u64::MAX elements",
553                &[0x9B, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF][..],
554            ),
555            ("u32::MAX elements", &[0x9A, 0xFF, 0xFF, 0xFF, 0xFF][..]),
556            ("1,000,000 elements", &[0x9A, 0x00, 0x0F, 0x42, 0x40][..]),
557        ] {
558            let bytes = legacy_bytes_with_raw_payload(header);
559            assert!(
560                ActionContentV1::decode(&bytes).is_err(),
561                "{label}: a lying array header must be a decode error, never a panic/abort"
562            );
563        }
564    }
565
566    /// The out-of-range guard in `visit_seq`. Without it a `byte as u8`
567    /// truncation would silently corrupt the payload instead of rejecting it.
568    #[test]
569    fn legacy_payload_with_out_of_range_element_is_rejected() {
570        // [300] — a single element that is not a byte.
571        let bad = legacy_bytes_with_raw_payload(&[0x81, 0x19, 0x01, 0x2C]);
572        assert!(
573            ActionContentV1::decode(&bad).is_err(),
574            "an element above 255 must be rejected, not truncated"
575        );
576
577        // [1, 300] — must not accept the valid prefix then truncate.
578        let mixed = legacy_bytes_with_raw_payload(&[0x82, 0x01, 0x19, 0x01, 0x2C]);
579        assert!(
580            ActionContentV1::decode(&mixed).is_err(),
581            "a trailing out-of-range element must reject the whole payload"
582        );
583    }
584
585    /// Legacy decode must cover every action kind, not just `edit`:
586    /// - `delete` carries an EMPTY payload (`0x80` legacy / `0x40` new), a
587    ///   distinct branch from a populated one.
588    /// - reaction emoji contain bytes >= 0x80, which the legacy form encodes
589    ///   as TWO-byte CBOR integers rather than one — a different code path
590    ///   through `visit_seq` than all-ASCII edit text.
591    /// - multi-byte UTF-8 edit text likewise crosses the one/two-byte boundary.
592    #[test]
593    fn legacy_decode_covers_every_action_kind() {
594        let cases = vec![
595            ActionContentV1::edit(test_message_id(), "plain ascii".to_string()),
596            ActionContentV1::edit(test_message_id(), "café 🎉 naïve".to_string()),
597            ActionContentV1::delete(test_message_id()),
598            ActionContentV1::reaction(test_message_id(), "👍".to_string()),
599            ActionContentV1::remove_reaction(test_message_id(), "❤️".to_string()),
600        ];
601
602        for action in cases {
603            let legacy_bytes = encode_cbor(&LegacyActionContentV1 {
604                action_type: action.action_type,
605                target: action.target.clone(),
606                payload: action.payload.clone(),
607            });
608            let decoded = ActionContentV1::decode(&legacy_bytes)
609                .unwrap_or_else(|e| panic!("legacy decode failed for {action:?}: {e}"));
610            assert_eq!(decoded, action, "legacy decode must be lossless");
611
612            // And the typed accessors must still yield the original content.
613            if action.action_type == ACTION_TYPE_EDIT {
614                assert_eq!(
615                    decoded.edit_payload().expect("edit payload").new_text,
616                    action.edit_payload().expect("edit payload").new_text
617                );
618            } else if action.action_type == ACTION_TYPE_REACTION
619                || action.action_type == ACTION_TYPE_REMOVE_REACTION
620            {
621                assert_eq!(
622                    decoded.reaction_payload().expect("reaction payload").emoji,
623                    action.reaction_payload().expect("reaction payload").emoji
624                );
625            }
626        }
627    }
628
629    /// FROZEN pre-#443 bytes for
630    /// `ActionContentV1::edit(test_message_id(), "café 🎉 ok")`, captured from
631    /// the legacy array-of-integers encoding.
632    ///
633    /// Do NOT regenerate this constant casually — it is the whole point. The
634    /// mirror-struct tests above rebuild "legacy" bytes from TODAY's types, so
635    /// they move with any change to `EditPayload`, `MessageId`, or ciborium
636    /// and would keep passing against bytes that no longer resemble what is
637    /// stored in real rooms. This literal cannot drift. If it starts failing,
638    /// a change has broken the ability to read action payloads written by
639    /// every already-deployed client — that is a migration problem, not a
640    /// test problem.
641    ///
642    /// The payload deliberately contains bytes below 0x18 (single-byte CBOR
643    /// ints) and above 0x80 (two-byte ints) so both integer widths of the
644    /// legacy encoding are exercised.
645    const LEGACY_EDIT_ACTION_PRE_443: &str = "a36b616374696f6e5f747970650166746172676574197c42677061796c6f6164981818a11868186e18651877185f1874186518781874186d18631861186618c318a9182018f0189f188e18891820186f186b";
646
647    fn hex_to_bytes(hex: &str) -> Vec<u8> {
648        (0..hex.len())
649            .step_by(2)
650            .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("valid hex"))
651            .collect()
652    }
653
654    #[test]
655    fn frozen_pre_443_bytes_still_decode() {
656        let bytes = hex_to_bytes(LEGACY_EDIT_ACTION_PRE_443);
657        let decoded = ActionContentV1::decode(&bytes)
658            .expect("bytes written by already-deployed clients must still decode");
659
660        assert_eq!(decoded.action_type, ACTION_TYPE_EDIT);
661        assert_eq!(decoded.target, test_message_id());
662        assert_eq!(
663            decoded.edit_payload().expect("edit payload").new_text,
664            "café 🎉 ok",
665            "the edited text of a pre-#443 stored edit must survive verbatim"
666        );
667    }
668
669    /// The rollout direction: a pre-#443 reader must still understand the new
670    /// byte-string encoding (ciborium's `deserialize_seq` accepts a byte
671    /// string), so a stale riverctl/UI does not lose newly-authored edits.
672    #[test]
673    fn new_byte_string_payload_decodes_with_legacy_reader() {
674        let action = ActionContentV1::edit(test_message_id(), "Edited text".to_string());
675        let new_bytes = action.encode();
676
677        let legacy: LegacyActionContentV1 = ciborium::from_reader(&new_bytes[..])
678            .expect("a pre-#443 reader must still decode the new encoding");
679        assert_eq!(legacy.payload, action.payload);
680        assert_eq!(legacy.action_type, action.action_type);
681        assert_eq!(legacy.target, action.target);
682    }
683
684    /// Regression pin for freenet/river#443. Before the fix an edit cost ~2.1
685    /// bytes per ASCII character (CBOR array of integers), so against the
686    /// default `max_message_size` of 1000 edits were capped at ~467 characters
687    /// while sends allowed ~985 — a message could be sent and never edited.
688    #[test]
689    fn edit_action_does_not_cost_two_bytes_per_character() {
690        let text = "a".repeat(900);
691        let encoded_len = ActionContentV1::edit(test_message_id(), text.clone())
692            .encode()
693            .len();
694
695        // Legacy encoding of the very same action, for contrast.
696        let legacy_len = {
697            let action = ActionContentV1::edit(test_message_id(), text.clone());
698            encode_cbor(&LegacyActionContentV1 {
699                action_type: action.action_type,
700                target: action.target.clone(),
701                payload: action.payload,
702            })
703            .len()
704        };
705
706        assert!(
707            legacy_len > 1800,
708            "sanity: the legacy encoding should be ~2x the text ({legacy_len} bytes)"
709        );
710        assert!(
711            encoded_len < 1000,
712            "a 900-char edit must fit the default 1000-byte limit, got {encoded_len} bytes"
713        );
714        assert!(
715            encoded_len < text.len() + 100,
716            "edit overhead must be roughly constant, not proportional: \
717             {encoded_len} bytes for {} chars",
718            text.len()
719        );
720    }
721
722    #[test]
723    fn test_edit_action_roundtrip() {
724        let action = ActionContentV1::edit(test_message_id(), "New text".to_string());
725        let encoded = action.encode();
726        let decoded = ActionContentV1::decode(&encoded).unwrap();
727        assert_eq!(action, decoded);
728
729        let payload = decoded.edit_payload().unwrap();
730        assert_eq!(payload.new_text, "New text");
731    }
732
733    #[test]
734    fn test_delete_action_roundtrip() {
735        let action = ActionContentV1::delete(test_message_id());
736        let encoded = action.encode();
737        let decoded = ActionContentV1::decode(&encoded).unwrap();
738        assert_eq!(action, decoded);
739        assert_eq!(decoded.action_type, ACTION_TYPE_DELETE);
740    }
741
742    #[test]
743    fn test_reaction_action_roundtrip() {
744        let action = ActionContentV1::reaction(test_message_id(), "👍".to_string());
745        let encoded = action.encode();
746        let decoded = ActionContentV1::decode(&encoded).unwrap();
747        assert_eq!(action, decoded);
748
749        let payload = decoded.reaction_payload().unwrap();
750        assert_eq!(payload.emoji, "👍");
751    }
752
753    #[test]
754    fn test_remove_reaction_action_roundtrip() {
755        let action = ActionContentV1::remove_reaction(test_message_id(), "❤️".to_string());
756        let encoded = action.encode();
757        let decoded = ActionContentV1::decode(&encoded).unwrap();
758        assert_eq!(action, decoded);
759
760        let payload = decoded.reaction_payload().unwrap();
761        assert_eq!(payload.emoji, "❤️");
762    }
763
764    #[test]
765    fn test_reply_content_roundtrip() {
766        let reply = ReplyContentV1::new(
767            "I agree!".to_string(),
768            test_message_id(),
769            "Alice".to_string(),
770            "The original message text here...".to_string(),
771        );
772        let encoded = reply.encode();
773        let decoded = ReplyContentV1::decode(&encoded).unwrap();
774        assert_eq!(reply, decoded);
775
776        // Verify DecodedContent::Reply returns text via as_text()
777        let dc = DecodedContent::Reply(reply.clone());
778        assert_eq!(dc.as_text(), Some("I agree!"));
779        assert_eq!(dc.to_display_string(), "I agree!");
780        assert!(!dc.is_action());
781    }
782
783    #[test]
784    fn test_decoded_content_display() {
785        let text = DecodedContent::Text(TextContentV1::new("Hello".to_string()));
786        assert_eq!(text.to_display_string(), "Hello");
787
788        let unknown = DecodedContent::Unknown {
789            content_type: 99,
790            content_version: 1,
791        };
792        assert!(unknown.to_display_string().contains("Unsupported"));
793    }
794
795    #[test]
796    fn test_event_content_roundtrip() {
797        let event = EventContentV1::join();
798        let encoded = event.encode();
799        let decoded = EventContentV1::decode(&encoded).unwrap();
800        assert_eq!(event, decoded);
801        assert_eq!(decoded.event_type, EVENT_TYPE_JOIN);
802
803        let dc = DecodedContent::Event(event);
804        assert!(dc.is_event());
805        assert!(!dc.is_action());
806        assert_eq!(dc.to_display_string(), "joined the room");
807    }
808
809    #[test]
810    fn test_join_event_message_body() {
811        let body = crate::room_state::message::RoomMessageBody::join_event();
812        assert!(body.is_event());
813        assert!(!body.is_action());
814        let decoded = body.decode_content().unwrap();
815        assert!(matches!(decoded, DecodedContent::Event(_)));
816    }
817}