Skip to main content

river_core/room_state/
message.rs

1use crate::room_state::member::MemberId;
2use crate::room_state::privacy::{PrivacyMode, SecretVersion};
3use crate::room_state::ChatRoomParametersV1;
4use crate::util::sign_struct;
5use crate::util::{truncated_base64, verify_struct};
6use crate::ChatRoomStateV1;
7use ed25519_dalek::{Signature, SigningKey, VerifyingKey};
8use freenet_scaffold::util::{fast_hash, FastHash};
9use freenet_scaffold::ComposableState;
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeSet, HashMap};
12use std::fmt;
13use std::time::SystemTime;
14
15/// Ciphertext overhead added by AES-256-GCM (`encrypt_with_symmetric_key`):
16/// the 16-byte authentication tag appended to the plaintext. The nonce lives
17/// in a separate field of [`RoomMessageBody::Private`] and does not count
18/// toward [`RoomMessageBody::content_len`]. Pinned against real encryption
19/// by the `measure_*_matches_private_*` tests (feature `ecies-randomized`).
20pub const ENCRYPTION_TAG_OVERHEAD: usize = 16;
21
22/// Computed state for message actions (edits, deletes, reactions)
23/// This is rebuilt from action messages and not serialized
24#[derive(Clone, PartialEq, Debug, Default)]
25pub struct MessageActionsState {
26    /// Messages that have been edited: message_id -> new text content
27    pub edited_content: HashMap<MessageId, String>,
28    /// Messages that have been deleted
29    pub deleted: std::collections::HashSet<MessageId>,
30    /// Reactions on messages: message_id -> (emoji -> list of reactors)
31    pub reactions: HashMap<MessageId, HashMap<String, Vec<MemberId>>>,
32}
33
34#[derive(Serialize, Deserialize, Clone, PartialEq, Debug, Default)]
35pub struct MessagesV1 {
36    pub messages: Vec<AuthorizedMessageV1>,
37    /// Computed state from action messages (not serialized - rebuilt on each delta)
38    #[serde(skip)]
39    pub actions_state: MessageActionsState,
40}
41
42/// The total order [`MessagesV1::apply_delta`] retains by: it sorts ascending
43/// on `(time, id)` and keeps the newest `max_recent_messages`, so this key
44/// alone decides whether a message survives a merge.
45///
46/// `id` breaks ties deterministically; without it two messages sharing a
47/// timestamp would order differently on different peers.
48#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
49pub struct MessageOrderKey {
50    pub time: SystemTime,
51    pub id: MessageId,
52}
53
54/// How much appetite a peer has for older messages, published in its
55/// [`MessagesSummary`] so a sender never offers a message the receiver would
56/// discard on arrival.
57///
58/// # Why this exists
59///
60/// `apply_delta` DROPS the oldest messages over `max_recent_messages`, which
61/// makes the merge non-monotonic: without a horizon, `delta` is a pure
62/// "everything you don't have" set-difference, so a peer holding an older
63/// window re-offers the same messages on every fan-out, the receiver re-prunes
64/// them, and neither side's summary ever changes. `delta` never returns `None`,
65/// the "empty delta -> skip" path in freenet-core's broadcast never fires, and
66/// the pair loops forever. That loop drove the 2026-07-25 bandwidth incident
67/// (the room contract reached 63.7% of all byte-weighted broadcast work
68/// network-wide).
69///
70/// # Why it terminates
71///
72/// [`RetentionHorizon::OldestRetained`] is the oldest key the peer currently
73/// holds, published only once it is AT capacity. A sender offers only keys
74/// strictly above it. Applying any such message pushes the peer over capacity,
75/// so the prune drops at least the horizon message itself and the horizon
76/// strictly increases. A peer BELOW capacity publishes [`RetentionHorizon::Open`]
77/// and discards nothing, so its id set only grows. Each exchange therefore
78/// either grows a bounded set or strictly advances a bounded key: no cycles.
79///
80/// Deliberately conservative — publishing the oldest HELD key rather than the
81/// exact post-merge cutoff means a peer may occasionally accept a message it
82/// then prunes, costing one extra round. The opposite error (an over-stated
83/// horizon) would silently drop messages the peer would have kept, so the
84/// conservative direction is the safe one.
85#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
86pub enum RetentionHorizon {
87    /// The peer holds fewer than `max_recent_messages`; it retains anything.
88    Open,
89    /// The peer is at (or over) capacity and holds nothing older than this key.
90    /// Anything ordering strictly before it is discarded on arrival.
91    OldestRetained(MessageOrderKey),
92    /// `max_recent_messages == 0`: the peer retains no messages at all.
93    ///
94    /// `AuthorizedConfigurationV1::apply_delta` rejects a zero cap, but
95    /// `verify` does not, so an owner-signed zero can still arrive on the
96    /// full-state path. Represented explicitly rather than folded into
97    /// `OldestRetained` so the sender suppresses the delta instead of looping
98    /// against a peer that keeps nothing.
99    Closed,
100}
101
102/// Summary of the messages a peer holds, plus the retention horizon a sender
103/// needs in order to avoid offering messages the peer would immediately prune.
104///
105/// `BTreeSet` (not `HashSet`, and not a `Vec` inheriting the state's own
106/// ordering) so the ciborium bytes are canonical for a given logical set:
107/// freenet-core byte-compares `summarize_state` output for staleness, and a
108/// non-canonical order makes two identical peers look perpetually out of sync.
109/// See `.claude/rules/contract-summary-determinism.md` and
110/// freenet/freenet-core#4857.
111#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
112pub struct MessagesSummary {
113    pub message_ids: BTreeSet<MessageId>,
114    pub horizon: RetentionHorizon,
115}
116
117impl ComposableState for MessagesV1 {
118    type ParentState = ChatRoomStateV1;
119    type Summary = MessagesSummary;
120    type Delta = Vec<AuthorizedMessageV1>;
121    type Parameters = ChatRoomParametersV1;
122
123    fn verify(
124        &self,
125        parent_state: &Self::ParentState,
126        parameters: &Self::Parameters,
127    ) -> Result<(), String> {
128        let members_by_id = parent_state.members.members_by_member_id();
129        let owner_id = parameters.owner_id();
130
131        for message in &self.messages {
132            let verifying_key = if message.message.author == owner_id {
133                // Owner's messages are validated against the owner's key
134                &parameters.owner
135            } else if let Some(member) = members_by_id.get(&message.message.author) {
136                // Regular member messages are validated against their member key
137                &member.member.member_vk
138            } else {
139                return Err(format!(
140                    "Message author not found: {:?}",
141                    message.message.author
142                ));
143            };
144
145            if message.validate(verifying_key).is_err() {
146                return Err(format!("Invalid message signature: id:{:?}", message.id()));
147            }
148        }
149
150        Ok(())
151    }
152
153    /// NOTE: unlike every other field in `ChatRoomStateV1`, this `summarize`
154    /// READS `parent_state` (for `max_recent_messages`, which sizes the
155    /// retention horizon). Callers must pass the SUMMARIZING peer's own state,
156    /// which is what `summarize_state` in the contract and every `merge` call
157    /// site do. Passing a cheap `ChatRoomStateV1::default()` sentinel — as
158    /// `room_synchronizer` used to — reads the DEFAULT cap instead of the
159    /// room's, understating the horizon and re-opening the resend loop.
160    /// Pinned by `merge_uses_room_state_as_parent_so_horizon_is_correct`.
161    fn summarize(
162        &self,
163        parent_state: &Self::ParentState,
164        _parameters: &Self::Parameters,
165    ) -> Self::Summary {
166        MessagesSummary {
167            message_ids: self.messages.iter().map(|m| m.id()).collect(),
168            horizon: self
169                .retention_horizon(parent_state.configuration.configuration.max_recent_messages),
170        }
171    }
172
173    /// Deliberately does NOT read `parent_state`: the default
174    /// `ComposableState::merge` passes the RECEIVER's state as `parent_state`
175    /// when asking the SENDER for a delta, so anything read from it here would
176    /// be the wrong peer's configuration. Everything this needs about the
177    /// receiver travels in `old_state_summary`.
178    fn delta(
179        &self,
180        _parent_state: &Self::ParentState,
181        _parameters: &Self::Parameters,
182        old_state_summary: &Self::Summary,
183    ) -> Option<Self::Delta> {
184        // A message the receiver would prune the instant it applied it must
185        // never be offered, or the pair loops forever re-sending it.
186        let retained_by_receiver = |m: &AuthorizedMessageV1| match &old_state_summary.horizon {
187            RetentionHorizon::Open => true,
188            RetentionHorizon::OldestRetained(oldest) => m.order_key() > *oldest,
189            RetentionHorizon::Closed => false,
190        };
191
192        let delta: Vec<AuthorizedMessageV1> = self
193            .messages
194            .iter()
195            .filter(|m| !old_state_summary.message_ids.contains(&m.id()))
196            .filter(|m| retained_by_receiver(m))
197            .cloned()
198            .collect();
199        if delta.is_empty() {
200            None
201        } else {
202            Some(delta)
203        }
204    }
205
206    fn apply_delta(
207        &mut self,
208        parent_state: &Self::ParentState,
209        parameters: &Self::Parameters,
210        delta: &Option<Self::Delta>,
211    ) -> Result<(), String> {
212        let max_recent_messages = parent_state.configuration.configuration.max_recent_messages;
213        let max_message_size = parent_state.configuration.configuration.max_message_size;
214        let privacy_mode = &parent_state.configuration.configuration.privacy_mode;
215
216        // Validate message constraints before adding
217        if let Some(delta) = delta {
218            for msg in delta {
219                let content = &msg.message.content;
220
221                match content {
222                    RoomMessageBody::Private { secret_version, .. } => {
223                        // In private mode, accept any secret_version that has a
224                        // corresponding signed record in `parent_state.secrets.versions`.
225                        //
226                        // Previously this required `secret_version == current_version`
227                        // AND `has_complete_distribution` to be true for every current
228                        // member. That was too strict in two ways:
229                        //
230                        // 1. **Strict-version mismatch (Bug #3, Ivvor's repro):** if the
231                        //    owner has rotated to v_new (e.g. after a ban or membership
232                        //    churn) and sends a message at v_new, but the invitee's
233                        //    secrets-state hasn't caught up yet (still at v_old, or has
234                        //    v_old + v_new but `current_version` is still v_old), the
235                        //    composable `apply_delta` short-circuited via `?` and dropped
236                        //    the entire delta — including the message itself,
237                        //    membership updates, and any secrets-delta in the same
238                        //    payload. The invitee's UI would never even see the
239                        //    encrypted message; back-fill became impossible.
240                        //
241                        // 2. **Complete-distribution freeze:** a single member missing a
242                        //    blob at `current_version` froze the entire room for
243                        //    messages, with no recovery path unless that member came
244                        //    online and the owner re-issued blobs.
245                        //
246                        // Author safety is already enforced by `MessagesV1::verify`'s
247                        // member-or-owner signature check (see lines 47-66 above) and by
248                        // `ChatRoomStateV1::post_apply_cleanup`'s ban sweep. The
249                        // secret_version → version-record cross-check below ensures
250                        // the message references a real, owner-signed version, so a
251                        // malicious peer can't inject ciphertext at a fabricated
252                        // version number.
253                        //
254                        // **Trade-off acknowledged (Codex review, 2026-05-17):** this
255                        // relaxation permits a member with a stale client to send a
256                        // message encrypted at an older `secret_version` AFTER the
257                        // room has rotated. Members previously holding that older
258                        // secret (e.g. banned members) could still decrypt such a
259                        // message. We accept this because:
260                        //   - banned members already hold the plaintext of ALL
261                        //     messages sent during the old version's tenure, so the
262                        //     marginal post-rotation exposure is small and bounded
263                        //     by how quickly senders catch up to the latest version;
264                        //   - the alternative (`secret_version == current_version`,
265                        //     i.e. the pre-fix rule) is what produced Bug #3 in the
266                        //     first place — receivers whose own state lagged the
267                        //     sender's `current_version` dropped every message they
268                        //     received, including legitimate ones from non-stale
269                        //     senders;
270                        //   - confidentiality of post-rotation messages is properly
271                        //     enforced at the SENDER, not the contract: senders
272                        //     should always encrypt with the latest secret they
273                        //     have. PR B will add the UI back-fill needed for
274                        //     stragglers to rotate forward.
275                        if *privacy_mode == PrivacyMode::Private
276                            && !parent_state
277                                .secrets
278                                .versions
279                                .iter()
280                                .any(|v| v.record.version == *secret_version)
281                        {
282                            return Err(format!(
283                                "Private message references unknown secret version {}",
284                                secret_version
285                            ));
286                        }
287                    }
288                    RoomMessageBody::Public { .. } => {
289                        // In private mode, reject public messages (everything must be encrypted)
290                        // Exception: event messages (joins, etc.) contain no sensitive content
291                        if *privacy_mode == PrivacyMode::Private && !content.is_event() {
292                            return Err("Cannot send public messages in private room".to_string());
293                        }
294                    }
295                }
296            }
297
298            // Deduplicate by message ID to prevent duplicate messages from race conditions
299            let existing_ids: std::collections::HashSet<_> =
300                self.messages.iter().map(|m| m.id()).collect();
301            self.messages.extend(
302                delta
303                    .iter()
304                    .filter(|msg| !existing_ids.contains(&msg.id()))
305                    .cloned(),
306            );
307        }
308
309        // Always enforce message constraints
310        // Ensure there are no messages over the size limit
311        self.messages
312            .retain(|m| m.message.content.content_len() <= max_message_size);
313
314        // Ensure all messages are signed by a valid member or the room owner, remove if not
315        let members_by_id = parent_state.members.members_by_member_id();
316        let owner_id = MemberId::from(&parameters.owner);
317        self.messages.retain(|m| {
318            members_by_id.contains_key(&m.message.author) || m.message.author == owner_id
319        });
320
321        // Sort messages by time, with MessageId as secondary sort for deterministic ordering
322        // (CRDT convergence requirement - without this, ties produce non-deterministic order)
323        self.messages.sort_by(|a, b| {
324            a.message
325                .time
326                .cmp(&b.message.time)
327                .then_with(|| a.id().cmp(&b.id()))
328        });
329
330        // Remove oldest messages if there are too many.
331        //
332        // This removal is what makes the merge non-monotonic, so it MUST stay
333        // paired with the retention horizon that `summarize` publishes and
334        // `delta` filters on — see [`RetentionHorizon`]. Changing the retention
335        // rule here (a different sort key, a cap on a different axis) without
336        // teaching `retention_horizon` about it re-opens the resend loop.
337        if self.messages.len() > max_recent_messages {
338            self.messages
339                .drain(0..self.messages.len() - max_recent_messages);
340        }
341
342        // Rebuild computed state from action messages
343        self.rebuild_actions_state();
344
345        Ok(())
346    }
347}
348
349impl MessagesV1 {
350    /// The [`RetentionHorizon`] this peer publishes for the given
351    /// `max_recent_messages`.
352    ///
353    /// Computed as the MINIMUM held key rather than the exact post-prune
354    /// cutoff, and only once the peer is at or over capacity. Two consequences
355    /// worth stating:
356    ///
357    /// * It never over-states, so a sender is never told to withhold a message
358    ///   the peer would in fact have kept. Under-stating merely costs an extra
359    ///   round (the horizon strictly rises each time, so it still terminates).
360    /// * It does not assume `self.messages` is sorted. `apply_delta` keeps it
361    ///   sorted, but `verify` does not enforce that, so a hand-built or hostile
362    ///   full-state PUT could arrive unsorted; taking the min is correct either
363    ///   way and avoids an out-of-bounds index.
364    pub fn retention_horizon(&self, max_recent_messages: usize) -> RetentionHorizon {
365        if max_recent_messages == 0 {
366            return RetentionHorizon::Closed;
367        }
368        if self.messages.len() < max_recent_messages {
369            return RetentionHorizon::Open;
370        }
371        match self.messages.iter().map(|m| m.order_key()).min() {
372            Some(oldest) => RetentionHorizon::OldestRetained(oldest),
373            // Unreachable: len >= max_recent_messages >= 1 means non-empty.
374            // `Open` is the safe fallback (offers more, never drops).
375            None => RetentionHorizon::Open,
376        }
377    }
378
379    /// Rebuild the computed actions state by scanning all action messages.
380    ///
381    /// This method only processes PUBLIC action messages. For private rooms,
382    /// use `rebuild_actions_state_with_decrypted` and provide the decrypted
383    /// content for each private action message.
384    pub fn rebuild_actions_state(&mut self) {
385        self.rebuild_actions_state_with_decrypted(&HashMap::new());
386    }
387
388    /// Rebuild actions state with decrypted content for private action messages.
389    ///
390    /// For private rooms, the caller should decrypt each private action message
391    /// and provide the plaintext bytes in `decrypted_content`, keyed by message ID.
392    ///
393    /// # Arguments
394    /// * `decrypted_content` - Map of message_id -> decrypted plaintext bytes for
395    ///   private action messages. Public actions are decoded directly.
396    pub fn rebuild_actions_state_with_decrypted(
397        &mut self,
398        decrypted_content: &HashMap<MessageId, Vec<u8>>,
399    ) {
400        use crate::room_state::content::{
401            ActionContentV1, DecodedContent, ACTION_TYPE_DELETE, ACTION_TYPE_EDIT,
402            ACTION_TYPE_REACTION, ACTION_TYPE_REMOVE_REACTION,
403        };
404
405        // Clear existing computed state
406        self.actions_state = MessageActionsState::default();
407
408        // Build a map of message_id -> author for authorization checks
409        let message_authors: HashMap<MessageId, MemberId> = self
410            .messages
411            .iter()
412            .filter(|m| !m.message.content.is_action())
413            .map(|m| (m.id(), m.message.author))
414            .collect();
415
416        // Process action messages in timestamp order (messages are already sorted)
417        for msg in &self.messages {
418            let actor = msg.message.author;
419
420            // Skip non-action messages
421            if !msg.message.content.is_action() {
422                continue;
423            }
424
425            // Decode the action content - either from public data or decrypted bytes
426            let action = match &msg.message.content {
427                RoomMessageBody::Public { .. } => {
428                    // Public action - decode directly
429                    match msg.message.content.decode_content() {
430                        Some(DecodedContent::Action(action)) => action,
431                        _ => continue,
432                    }
433                }
434                RoomMessageBody::Private { .. } => {
435                    // Private action - use provided decrypted content
436                    let msg_id = msg.id();
437                    if let Some(plaintext) = decrypted_content.get(&msg_id) {
438                        match ActionContentV1::decode(plaintext) {
439                            Ok(action) => action,
440                            Err(_) => continue,
441                        }
442                    } else {
443                        // No decrypted content provided - skip this action
444                        continue;
445                    }
446                }
447            };
448
449            let target = &action.target;
450
451            match action.action_type {
452                ACTION_TYPE_EDIT => {
453                    // Only the original author can edit their message
454                    if let Some(&original_author) = message_authors.get(target) {
455                        if actor == original_author {
456                            // Don't allow editing deleted messages
457                            if !self.actions_state.deleted.contains(target) {
458                                if let Some(payload) = action.edit_payload() {
459                                    self.actions_state
460                                        .edited_content
461                                        .insert(target.clone(), payload.new_text);
462                                }
463                            }
464                        }
465                    }
466                }
467                ACTION_TYPE_DELETE => {
468                    // Only the original author can delete their message
469                    if let Some(&original_author) = message_authors.get(target) {
470                        if actor == original_author {
471                            self.actions_state.deleted.insert(target.clone());
472                            // Also remove any edited content for deleted messages
473                            self.actions_state.edited_content.remove(target);
474                        }
475                    }
476                }
477                ACTION_TYPE_REACTION => {
478                    // Anyone can add reactions to non-deleted messages
479                    if message_authors.contains_key(target)
480                        && !self.actions_state.deleted.contains(target)
481                    {
482                        if let Some(payload) = action.reaction_payload() {
483                            let reactions = self
484                                .actions_state
485                                .reactions
486                                .entry(target.clone())
487                                .or_default();
488                            let reactors = reactions.entry(payload.emoji).or_default();
489                            // Idempotent: only add if not already present
490                            if !reactors.contains(&actor) {
491                                reactors.push(actor);
492                            }
493                        }
494                    }
495                }
496                ACTION_TYPE_REMOVE_REACTION => {
497                    // Users can only remove their own reactions
498                    if let Some(payload) = action.reaction_payload() {
499                        if let Some(reactions) = self.actions_state.reactions.get_mut(target) {
500                            if let Some(reactors) = reactions.get_mut(&payload.emoji) {
501                                reactors.retain(|r| r != &actor);
502                                // Clean up empty entries
503                                if reactors.is_empty() {
504                                    reactions.remove(&payload.emoji);
505                                }
506                            }
507                            if reactions.is_empty() {
508                                self.actions_state.reactions.remove(target);
509                            }
510                        }
511                    }
512                }
513                _ => {
514                    // Unknown action type - ignore for forward compatibility
515                }
516            }
517        }
518    }
519
520    /// Check if a message has been edited
521    pub fn is_edited(&self, message_id: &MessageId) -> bool {
522        self.actions_state.edited_content.contains_key(message_id)
523    }
524
525    /// Check if a message has been deleted
526    pub fn is_deleted(&self, message_id: &MessageId) -> bool {
527        self.actions_state.deleted.contains(message_id)
528    }
529
530    /// Get the effective text content for a message (edited content if edited, original otherwise)
531    /// Returns the text content as a string, or None if the message is encrypted/undecodable
532    pub fn effective_text(&self, message: &AuthorizedMessageV1) -> Option<String> {
533        let id = message.id();
534        // Check if there's edited content first
535        if let Some(edited_text) = self.actions_state.edited_content.get(&id) {
536            return Some(edited_text.clone());
537        }
538        // Otherwise return the original content's text
539        message.message.content.as_public_string()
540    }
541
542    /// Get reactions for a message
543    pub fn reactions(&self, message_id: &MessageId) -> Option<&HashMap<String, Vec<MemberId>>> {
544        self.actions_state.reactions.get(message_id)
545    }
546
547    /// Get all non-deleted, non-action messages for display
548    pub fn display_messages(&self) -> impl Iterator<Item = &AuthorizedMessageV1> {
549        self.messages.iter().filter(|m| {
550            !m.message.content.is_action() && !self.actions_state.deleted.contains(&m.id())
551        })
552    }
553}
554
555/// Message body that can be either public or private (encrypted).
556///
557/// Content is opaque to the contract - interpretation happens client-side.
558/// This design enables adding new content types without contract redeployment.
559///
560/// # Content Types
561/// - `content_type = 1`: Text message (TextContentV1)
562/// - `content_type = 2`: Action on another message (ActionContentV1)
563/// - `content_type = 3`: Reply to another message (ReplyContentV1)
564/// - `content_type = 4`: Room event like join/leave (EventContentV1)
565///   - Allowed as Public even in private rooms (contains no sensitive content)
566///   - Old clients display as "[Unsupported message type 4.1 - please upgrade]"
567/// - Future types can be added without contract changes
568///
569/// # Extensibility
570/// - New content types: Just use a new content_type number
571/// - New action types: Just use a new action_type number within ActionContentV1
572/// - New fields: Add to content structs (old clients ignore unknown fields)
573/// - Breaking changes: Bump content_version
574/// # Do NOT apply `serde_bytes` to `data` / `ciphertext`
575///
576/// Both are bare `Vec<u8>`, so like `ActionContentV1::payload` before
577/// freenet/river#443 they serialize as a CBOR array of integers (~2 bytes per
578/// byte). That looks like the same easy win, and it is NOT: these fields live
579/// inside `MessageV1`, which `verify_struct` RE-SERIALIZES to check the
580/// signature (`AuthorizedMessageV1::verify`). Changing their encoding would
581/// invalidate the signature of **every existing message in every room** —
582/// unlike `ActionContentV1`, which is pre-encoded into these opaque bytes and
583/// is therefore outside the signed representation.
584///
585/// The #443 fix was safe precisely because it stopped at that boundary. If the
586/// on-wire size of message bodies ever needs to shrink, it requires a versioned
587/// migration, not a serde attribute.
588#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
589pub enum RoomMessageBody {
590    /// Public (unencrypted) message
591    Public {
592        /// Content type identifier (see content module for constants)
593        content_type: u32,
594        /// Version of the content format
595        content_version: u32,
596        /// CBOR-encoded content bytes.
597        ///
598        /// Do NOT add `serde(with = "serde_bytes")` — see the type-level note.
599        data: Vec<u8>,
600    },
601    /// Private (encrypted) message
602    Private {
603        /// Content type identifier (see content module for constants)
604        content_type: u32,
605        /// Version of the content format
606        content_version: u32,
607        /// Encrypted CBOR-encoded content.
608        ///
609        /// Do NOT add `serde(with = "serde_bytes")` — see the type-level note.
610        ciphertext: Vec<u8>,
611        /// Nonce used for encryption
612        nonce: [u8; 12],
613        /// Version of the room secret used for encryption
614        secret_version: SecretVersion,
615    },
616}
617
618impl RoomMessageBody {
619    /// Create a new public text message
620    pub fn public(text: String) -> Self {
621        use crate::room_state::content::{TextContentV1, CONTENT_TYPE_TEXT, TEXT_CONTENT_VERSION};
622        let content = TextContentV1::new(text);
623        Self::Public {
624            content_type: CONTENT_TYPE_TEXT,
625            content_version: TEXT_CONTENT_VERSION,
626            data: content.encode(),
627        }
628    }
629
630    /// Create a join event message
631    pub fn join_event() -> Self {
632        use crate::room_state::content::{
633            EventContentV1, CONTENT_TYPE_EVENT, EVENT_CONTENT_VERSION,
634        };
635        let content = EventContentV1::join();
636        Self::Public {
637            content_type: CONTENT_TYPE_EVENT,
638            content_version: EVENT_CONTENT_VERSION,
639            data: content.encode(),
640        }
641    }
642
643    /// Create a new public message with raw content
644    pub fn public_raw(content_type: u32, content_version: u32, data: Vec<u8>) -> Self {
645        Self::Public {
646            content_type,
647            content_version,
648            data,
649        }
650    }
651
652    /// Create a new private message
653    pub fn private(
654        content_type: u32,
655        content_version: u32,
656        ciphertext: Vec<u8>,
657        nonce: [u8; 12],
658        secret_version: SecretVersion,
659    ) -> Self {
660        Self::Private {
661            content_type,
662            content_version,
663            ciphertext,
664            nonce,
665            secret_version,
666        }
667    }
668
669    /// Create a private text message (convenience method)
670    pub fn private_text(
671        ciphertext: Vec<u8>,
672        nonce: [u8; 12],
673        secret_version: SecretVersion,
674    ) -> Self {
675        use crate::room_state::content::{CONTENT_TYPE_TEXT, TEXT_CONTENT_VERSION};
676        Self::Private {
677            content_type: CONTENT_TYPE_TEXT,
678            content_version: TEXT_CONTENT_VERSION,
679            ciphertext,
680            nonce,
681            secret_version,
682        }
683    }
684
685    /// Create an edit action (public)
686    pub fn edit(target: MessageId, new_text: String) -> Self {
687        use crate::room_state::content::{
688            ActionContentV1, ACTION_CONTENT_VERSION, CONTENT_TYPE_ACTION,
689        };
690        let action = ActionContentV1::edit(target, new_text);
691        Self::Public {
692            content_type: CONTENT_TYPE_ACTION,
693            content_version: ACTION_CONTENT_VERSION,
694            data: action.encode(),
695        }
696    }
697
698    /// Create a delete action (public)
699    pub fn delete(target: MessageId) -> Self {
700        use crate::room_state::content::{
701            ActionContentV1, ACTION_CONTENT_VERSION, CONTENT_TYPE_ACTION,
702        };
703        let action = ActionContentV1::delete(target);
704        Self::Public {
705            content_type: CONTENT_TYPE_ACTION,
706            content_version: ACTION_CONTENT_VERSION,
707            data: action.encode(),
708        }
709    }
710
711    /// Create a reaction action (public)
712    pub fn reaction(target: MessageId, emoji: String) -> Self {
713        use crate::room_state::content::{
714            ActionContentV1, ACTION_CONTENT_VERSION, CONTENT_TYPE_ACTION,
715        };
716        let action = ActionContentV1::reaction(target, emoji);
717        Self::Public {
718            content_type: CONTENT_TYPE_ACTION,
719            content_version: ACTION_CONTENT_VERSION,
720            data: action.encode(),
721        }
722    }
723
724    /// Create a remove reaction action (public)
725    pub fn remove_reaction(target: MessageId, emoji: String) -> Self {
726        use crate::room_state::content::{
727            ActionContentV1, ACTION_CONTENT_VERSION, CONTENT_TYPE_ACTION,
728        };
729        let action = ActionContentV1::remove_reaction(target, emoji);
730        Self::Public {
731            content_type: CONTENT_TYPE_ACTION,
732            content_version: ACTION_CONTENT_VERSION,
733            data: action.encode(),
734        }
735    }
736
737    /// Create a public reply message
738    pub fn reply(
739        text: String,
740        target_message_id: MessageId,
741        target_author_name: String,
742        target_content_preview: String,
743    ) -> Self {
744        use crate::room_state::content::{
745            ReplyContentV1, CONTENT_TYPE_REPLY, REPLY_CONTENT_VERSION,
746        };
747        let reply = ReplyContentV1::new(
748            text,
749            target_message_id,
750            target_author_name,
751            target_content_preview,
752        );
753        Self::Public {
754            content_type: CONTENT_TYPE_REPLY,
755            content_version: REPLY_CONTENT_VERSION,
756            data: reply.encode(),
757        }
758    }
759
760    /// Create a private action message (encrypted)
761    ///
762    /// Use this for any action (edit, delete, reaction, remove_reaction) in a private room.
763    /// The caller should:
764    /// 1. Create the ActionContentV1 (e.g., `ActionContentV1::edit(target, new_text)`)
765    /// 2. Encode it: `action.encode()`
766    /// 3. Encrypt the bytes with the room secret
767    /// 4. Pass the ciphertext here
768    pub fn private_action(
769        ciphertext: Vec<u8>,
770        nonce: [u8; 12],
771        secret_version: SecretVersion,
772    ) -> Self {
773        use crate::room_state::content::{ACTION_CONTENT_VERSION, CONTENT_TYPE_ACTION};
774        Self::Private {
775            content_type: CONTENT_TYPE_ACTION,
776            content_version: ACTION_CONTENT_VERSION,
777            ciphertext,
778            nonce,
779            secret_version,
780        }
781    }
782
783    /// Check if this is a public message
784    pub fn is_public(&self) -> bool {
785        matches!(self, Self::Public { .. })
786    }
787
788    /// Check if this is a private message
789    pub fn is_private(&self) -> bool {
790        matches!(self, Self::Private { .. })
791    }
792
793    /// Get the content type
794    pub fn content_type(&self) -> u32 {
795        match self {
796            Self::Public { content_type, .. } | Self::Private { content_type, .. } => *content_type,
797        }
798    }
799
800    /// Get the content version
801    pub fn content_version(&self) -> u32 {
802        match self {
803            Self::Public {
804                content_version, ..
805            }
806            | Self::Private {
807                content_version, ..
808            } => *content_version,
809        }
810    }
811
812    /// Check if this is an action message (content_type = ACTION)
813    pub fn is_action(&self) -> bool {
814        use crate::room_state::content::CONTENT_TYPE_ACTION;
815        self.content_type() == CONTENT_TYPE_ACTION
816    }
817
818    /// Check if this is an event message (content_type = EVENT)
819    pub fn is_event(&self) -> bool {
820        use crate::room_state::content::CONTENT_TYPE_EVENT;
821        self.content_type() == CONTENT_TYPE_EVENT
822    }
823
824    /// Decode the content (for public messages only)
825    /// Returns None for private messages - decrypt first
826    pub fn decode_content(&self) -> Option<crate::room_state::content::DecodedContent> {
827        use crate::room_state::content::{
828            ActionContentV1, DecodedContent, EventContentV1, ReplyContentV1, TextContentV1,
829            CONTENT_TYPE_ACTION, CONTENT_TYPE_EVENT, CONTENT_TYPE_REPLY, CONTENT_TYPE_TEXT,
830        };
831        match self {
832            Self::Public {
833                content_type,
834                content_version,
835                data,
836            } => match *content_type {
837                CONTENT_TYPE_TEXT => TextContentV1::decode(data).ok().map(DecodedContent::Text),
838                CONTENT_TYPE_ACTION => ActionContentV1::decode(data)
839                    .ok()
840                    .map(DecodedContent::Action),
841                CONTENT_TYPE_REPLY => ReplyContentV1::decode(data).ok().map(DecodedContent::Reply),
842                CONTENT_TYPE_EVENT => EventContentV1::decode(data).ok().map(DecodedContent::Event),
843                _ => Some(DecodedContent::Unknown {
844                    content_type: *content_type,
845                    content_version: *content_version,
846                }),
847            },
848            Self::Private { .. } => None,
849        }
850    }
851
852    /// Get the target message ID if this is an action
853    pub fn target_id(&self) -> Option<MessageId> {
854        use crate::room_state::content::{ActionContentV1, CONTENT_TYPE_ACTION};
855        match self {
856            Self::Public {
857                content_type, data, ..
858            } if *content_type == CONTENT_TYPE_ACTION => {
859                ActionContentV1::decode(data).ok().map(|a| a.target)
860            }
861            _ => None,
862        }
863    }
864
865    /// Get the content length for validation (contract uses this for size limits)
866    pub fn content_len(&self) -> usize {
867        match self {
868            Self::Public { data, .. } => data.len(),
869            Self::Private { ciphertext, .. } => ciphertext.len(),
870        }
871    }
872
873    /// Exact [`Self::content_len`] of the body [`Self::public`] builds for
874    /// `text` — or, with `encrypted`, of the private body the senders build
875    /// by AES-256-GCM-sealing the encoded `TextContentV1`.
876    ///
877    /// Send gates and byte counters MUST use the `measure_*` functions, not
878    /// `text.len()`: the contract validates encoded content bytes (CBOR
879    /// framing, plus the AEAD tag in private rooms), so a raw-text gate
880    /// passes messages the contract then silently prunes (freenet/river#430,
881    /// the "message was lost" reports).
882    pub fn measure_text(text: &str, encrypted: bool) -> usize {
883        use crate::room_state::content::TextContentV1;
884        let plain = TextContentV1::new(text.to_owned()).encode().len();
885        Self::with_encryption_overhead(plain, encrypted)
886    }
887
888    /// Exact [`Self::content_len`] of the body [`Self::reply`] builds — or,
889    /// with `encrypted`, of the private reply body (encrypted encoded
890    /// `ReplyContentV1`). Reply bodies embed the quoted author name and
891    /// content preview, so their overhead is much larger than plain text.
892    pub fn measure_reply(
893        text: &str,
894        target_message_id: MessageId,
895        target_author_name: &str,
896        target_content_preview: &str,
897        encrypted: bool,
898    ) -> usize {
899        use crate::room_state::content::ReplyContentV1;
900        let plain = ReplyContentV1::new(
901            text.to_owned(),
902            target_message_id,
903            target_author_name.to_owned(),
904            target_content_preview.to_owned(),
905        )
906        .encode()
907        .len();
908        Self::with_encryption_overhead(plain, encrypted)
909    }
910
911    /// Exact [`Self::content_len`] of the body [`Self::edit`] builds — or,
912    /// with `encrypted`, of the private edit body (encrypted encoded
913    /// `ActionContentV1`).
914    pub fn measure_edit(target: MessageId, new_text: &str, encrypted: bool) -> usize {
915        use crate::room_state::content::ActionContentV1;
916        let plain = ActionContentV1::edit(target, new_text.to_owned())
917            .encode()
918            .len();
919        Self::with_encryption_overhead(plain, encrypted)
920    }
921
922    fn with_encryption_overhead(plain_len: usize, encrypted: bool) -> usize {
923        if encrypted {
924            plain_len + ENCRYPTION_TAG_OVERHEAD
925        } else {
926            plain_len
927        }
928    }
929
930    /// Get the secret version (if private)
931    pub fn secret_version(&self) -> Option<SecretVersion> {
932        match self {
933            Self::Public { .. } => None,
934            Self::Private { secret_version, .. } => Some(*secret_version),
935        }
936    }
937
938    /// Get a string representation for display purposes
939    pub fn to_string_lossy(&self) -> String {
940        match self {
941            Self::Public { .. } => {
942                if let Some(decoded) = self.decode_content() {
943                    decoded.to_display_string()
944                } else {
945                    "[Failed to decode message]".to_string()
946                }
947            }
948            Self::Private {
949                ciphertext,
950                secret_version,
951                ..
952            } => {
953                format!(
954                    "[Encrypted message: {} bytes, v{}]",
955                    ciphertext.len(),
956                    secret_version
957                )
958            }
959        }
960    }
961
962    /// Try to get the public plaintext, returns None if private or not a text message
963    pub fn as_public_string(&self) -> Option<String> {
964        self.decode_content()
965            .and_then(|c| c.as_text().map(|s| s.to_string()))
966    }
967}
968
969impl fmt::Display for RoomMessageBody {
970    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
971        write!(f, "{}", self.to_string_lossy())
972    }
973}
974
975#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
976pub struct MessageV1 {
977    pub room_owner: MemberId,
978    pub author: MemberId,
979    pub time: SystemTime,
980    pub content: RoomMessageBody,
981}
982
983impl Default for MessageV1 {
984    fn default() -> Self {
985        Self {
986            room_owner: MemberId(FastHash(0)),
987            author: MemberId(FastHash(0)),
988            time: SystemTime::UNIX_EPOCH,
989            content: RoomMessageBody::public(String::new()),
990        }
991    }
992}
993
994#[derive(Clone, PartialEq, Serialize, Deserialize)]
995pub struct AuthorizedMessageV1 {
996    pub message: MessageV1,
997    pub signature: Signature,
998}
999
1000impl fmt::Debug for AuthorizedMessageV1 {
1001    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1002        f.debug_struct("AuthorizedMessage")
1003            .field("message", &self.message)
1004            .field(
1005                "signature",
1006                &format_args!("{}", truncated_base64(self.signature.to_bytes())),
1007            )
1008            .finish()
1009    }
1010}
1011
1012#[derive(Eq, PartialEq, Hash, Serialize, Deserialize, Clone, Debug, Ord, PartialOrd)]
1013pub struct MessageId(pub FastHash);
1014
1015impl fmt::Display for MessageId {
1016    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1017        write!(f, "{:?}", self.0)
1018    }
1019}
1020
1021impl AuthorizedMessageV1 {
1022    pub fn new(message: MessageV1, signing_key: &SigningKey) -> Self {
1023        Self {
1024            message: message.clone(),
1025            signature: sign_struct(&message, signing_key),
1026        }
1027    }
1028
1029    /// Create an AuthorizedMessageV1 with a pre-computed signature.
1030    /// Use this when signing is done externally (e.g., via delegate).
1031    pub fn with_signature(message: MessageV1, signature: Signature) -> Self {
1032        Self { message, signature }
1033    }
1034
1035    pub fn validate(
1036        &self,
1037        verifying_key: &VerifyingKey,
1038    ) -> Result<(), ed25519_dalek::SignatureError> {
1039        verify_struct(&self.message, &self.signature, verifying_key)
1040    }
1041
1042    pub fn id(&self) -> MessageId {
1043        MessageId(fast_hash(&self.signature.to_bytes()))
1044    }
1045
1046    /// This message's position in the retention order — the key
1047    /// [`MessagesV1::apply_delta`] sorts and prunes by. Must stay in step with
1048    /// that sort; see [`RetentionHorizon`].
1049    pub fn order_key(&self) -> MessageOrderKey {
1050        MessageOrderKey {
1051            time: self.message.time,
1052            id: self.id(),
1053        }
1054    }
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::*;
1060    use ed25519_dalek::{Signer, SigningKey};
1061    use rand::rngs::OsRng;
1062    use std::time::Duration;
1063
1064    fn create_test_message(owner_id: MemberId, author_id: MemberId) -> MessageV1 {
1065        MessageV1 {
1066            room_owner: owner_id,
1067            author: author_id,
1068            time: SystemTime::now(),
1069            content: RoomMessageBody::public("Test message".to_string()),
1070        }
1071    }
1072
1073    #[test]
1074    fn test_messages_v1_default() {
1075        let default_messages = MessagesV1::default();
1076        assert!(default_messages.messages.is_empty());
1077    }
1078
1079    /// Full-stack pin for freenet/river#443's backward compatibility.
1080    ///
1081    /// The unit tests in `content.rs` prove `ActionContentV1::decode` accepts
1082    /// the legacy array-of-integers payload, but the behaviour that actually
1083    /// matters is that `rebuild_actions_state` — the entry point the contract
1084    /// and every client run after `apply_delta` — still SURFACES those
1085    /// pre-existing edits, deletes and reactions. A change to which decode
1086    /// path that routes through would break every already-stored action while
1087    /// the unit tests stayed green.
1088    #[test]
1089    fn legacy_encoded_actions_still_render_through_rebuild_actions_state() {
1090        use crate::room_state::content::{
1091            ActionContentV1, ACTION_TYPE_DELETE, ACTION_TYPE_EDIT, ACTION_TYPE_REACTION,
1092            CONTENT_TYPE_ACTION,
1093        };
1094
1095        /// Pre-#443 shape: bare `Vec<u8>` -> CBOR array of integers.
1096        #[derive(Serialize)]
1097        struct LegacyAction {
1098            action_type: u32,
1099            target: MessageId,
1100            payload: Vec<u8>,
1101        }
1102
1103        fn legacy_action_body(action: &ActionContentV1) -> RoomMessageBody {
1104            let mut data = Vec::new();
1105            ciborium::into_writer(
1106                &LegacyAction {
1107                    action_type: action.action_type,
1108                    target: action.target.clone(),
1109                    payload: action.payload.clone(),
1110                },
1111                &mut data,
1112            )
1113            .expect("serialize legacy action");
1114            RoomMessageBody::public_raw(CONTENT_TYPE_ACTION, 1, data)
1115        }
1116
1117        let signing_key = SigningKey::generate(&mut OsRng);
1118        let owner_id = MemberId(FastHash(0));
1119        let author_id = MemberId::from(&signing_key.verifying_key());
1120
1121        let original = AuthorizedMessageV1::new(
1122            MessageV1 {
1123                room_owner: owner_id,
1124                author: author_id,
1125                time: SystemTime::now(),
1126                content: RoomMessageBody::public("original text".to_string()),
1127            },
1128            &signing_key,
1129        );
1130        let target = original.id();
1131
1132        let push_action = |messages: &mut MessagesV1, action: ActionContentV1| {
1133            messages.messages.push(AuthorizedMessageV1::new(
1134                MessageV1 {
1135                    room_owner: owner_id,
1136                    author: author_id,
1137                    time: SystemTime::now() + Duration::from_secs(1),
1138                    content: legacy_action_body(&action),
1139                },
1140                &signing_key,
1141            ));
1142        };
1143
1144        // --- a legacy EDIT must still render ---
1145        let mut messages = MessagesV1 {
1146            messages: vec![original.clone()],
1147            ..Default::default()
1148        };
1149        push_action(
1150            &mut messages,
1151            ActionContentV1 {
1152                action_type: ACTION_TYPE_EDIT,
1153                target: target.clone(),
1154                payload: ActionContentV1::edit(target.clone(), "edited text".to_string()).payload,
1155            },
1156        );
1157        messages.rebuild_actions_state();
1158        assert!(
1159            messages.is_edited(&target),
1160            "a pre-#443 stored edit must still be seen as an edit"
1161        );
1162        assert_eq!(
1163            messages.effective_text(&original).as_deref(),
1164            Some("edited text"),
1165            "a pre-#443 stored edit must still render its new text"
1166        );
1167
1168        // --- a legacy REACTION (emoji -> bytes >= 0x80) must still render ---
1169        let mut messages = MessagesV1 {
1170            messages: vec![original.clone()],
1171            ..Default::default()
1172        };
1173        push_action(
1174            &mut messages,
1175            ActionContentV1 {
1176                action_type: ACTION_TYPE_REACTION,
1177                target: target.clone(),
1178                payload: ActionContentV1::reaction(target.clone(), "👍".to_string()).payload,
1179            },
1180        );
1181        messages.rebuild_actions_state();
1182        assert!(
1183            messages
1184                .actions_state
1185                .reactions
1186                .get(&target)
1187                .is_some_and(|r| r.contains_key("👍")),
1188            "a pre-#443 stored emoji reaction must still render"
1189        );
1190
1191        // --- a legacy DELETE (empty payload) must still apply ---
1192        let mut messages = MessagesV1 {
1193            messages: vec![original.clone()],
1194            ..Default::default()
1195        };
1196        push_action(
1197            &mut messages,
1198            ActionContentV1 {
1199                action_type: ACTION_TYPE_DELETE,
1200                target: target.clone(),
1201                payload: Vec::new(),
1202            },
1203        );
1204        messages.rebuild_actions_state();
1205        assert!(
1206            messages.is_deleted(&target),
1207            "a pre-#443 stored delete must still apply"
1208        );
1209    }
1210
1211    #[test]
1212    fn test_authorized_message_v1_debug() {
1213        let signing_key = SigningKey::generate(&mut OsRng);
1214        let owner_id = MemberId(FastHash(0));
1215        let author_id = MemberId(FastHash(1));
1216
1217        let message = create_test_message(owner_id, author_id);
1218        let authorized_message = AuthorizedMessageV1::new(message, &signing_key);
1219
1220        let debug_output = format!("{:?}", authorized_message);
1221        assert!(debug_output.contains("AuthorizedMessage"));
1222        assert!(debug_output.contains("message"));
1223        assert!(debug_output.contains("signature"));
1224    }
1225
1226    #[test]
1227    fn test_authorized_message_new_and_validate() {
1228        let signing_key = SigningKey::generate(&mut OsRng);
1229        let verifying_key = signing_key.verifying_key();
1230        let owner_id = MemberId(FastHash(0));
1231        let author_id = MemberId(FastHash(1));
1232
1233        let message = create_test_message(owner_id, author_id);
1234        let authorized_message = AuthorizedMessageV1::new(message.clone(), &signing_key);
1235
1236        assert_eq!(authorized_message.message, message);
1237        assert!(authorized_message.validate(&verifying_key).is_ok());
1238
1239        // Test with wrong key
1240        let wrong_key = SigningKey::generate(&mut OsRng).verifying_key();
1241        assert!(authorized_message.validate(&wrong_key).is_err());
1242
1243        // Test with tampered message
1244        let mut tampered_message = authorized_message.clone();
1245        tampered_message.message.content = RoomMessageBody::public("Tampered content".to_string());
1246        assert!(tampered_message.validate(&verifying_key).is_err());
1247    }
1248
1249    #[test]
1250    fn test_message_id() {
1251        let signing_key = SigningKey::generate(&mut OsRng);
1252        let owner_id = MemberId(FastHash(0));
1253        let author_id = MemberId(FastHash(1));
1254
1255        let message = create_test_message(owner_id, author_id);
1256        let authorized_message = AuthorizedMessageV1::new(message, &signing_key);
1257
1258        let id1 = authorized_message.id();
1259        let id2 = authorized_message.id();
1260
1261        assert_eq!(id1, id2);
1262
1263        // Test that different messages have different IDs
1264        let message2 = create_test_message(owner_id, author_id);
1265        let authorized_message2 = AuthorizedMessageV1::new(message2, &signing_key);
1266        assert_ne!(authorized_message.id(), authorized_message2.id());
1267    }
1268
1269    #[test]
1270    fn test_messages_verify() {
1271        // Generate a new signing key and its corresponding verifying key for the owner
1272        let owner_signing_key = SigningKey::generate(&mut OsRng);
1273        let owner_verifying_key = owner_signing_key.verifying_key();
1274        let owner_id = MemberId::from(&owner_verifying_key);
1275
1276        // Generate a signing key for the author
1277        let author_signing_key = SigningKey::generate(&mut OsRng);
1278        let author_verifying_key = author_signing_key.verifying_key();
1279        let author_id = MemberId::from(&author_verifying_key);
1280
1281        // Create a test message and authorize it with the author's signing key
1282        let message = create_test_message(owner_id, author_id);
1283        let authorized_message = AuthorizedMessageV1::new(message, &author_signing_key);
1284
1285        // Create a Messages struct with the authorized message
1286        let messages = MessagesV1 {
1287            messages: vec![authorized_message],
1288            ..Default::default()
1289        };
1290
1291        // Set up a parent room_state (ChatRoomState) with the author as a member
1292        let mut parent_state = ChatRoomStateV1::default();
1293        let author_member = crate::room_state::member::Member {
1294            owner_member_id: owner_id,
1295            invited_by: owner_id,
1296            member_vk: author_verifying_key,
1297        };
1298        let authorized_author =
1299            crate::room_state::member::AuthorizedMember::new(author_member, &owner_signing_key);
1300        parent_state.members.members = vec![authorized_author];
1301
1302        // Set up parameters for verification
1303        let parameters = ChatRoomParametersV1 {
1304            owner: owner_verifying_key,
1305        };
1306
1307        // Verify that a valid message passes verification
1308        assert!(
1309            messages.verify(&parent_state, &parameters).is_ok(),
1310            "Valid messages should pass verification: {:?}",
1311            messages.verify(&parent_state, &parameters)
1312        );
1313
1314        // Test with invalid signature
1315        let mut invalid_messages = messages.clone();
1316        invalid_messages.messages[0].signature = Signature::from_bytes(&[0; 64]); // Replace with an invalid signature
1317        assert!(
1318            invalid_messages.verify(&parent_state, &parameters).is_err(),
1319            "Messages with invalid signature should fail verification"
1320        );
1321
1322        // Test with non-existent author
1323        let non_existent_author_id =
1324            MemberId::from(&SigningKey::generate(&mut OsRng).verifying_key());
1325        let invalid_message = create_test_message(owner_id, non_existent_author_id);
1326        let invalid_authorized_message =
1327            AuthorizedMessageV1::new(invalid_message, &author_signing_key);
1328        let invalid_messages = MessagesV1 {
1329            messages: vec![invalid_authorized_message],
1330            ..Default::default()
1331        };
1332        assert!(
1333            invalid_messages.verify(&parent_state, &parameters).is_err(),
1334            "Messages with non-existent author should fail verification"
1335        );
1336    }
1337
1338    #[test]
1339    fn test_messages_summarize() {
1340        let signing_key = SigningKey::generate(&mut OsRng);
1341        let owner_id = MemberId(FastHash(0));
1342        let author_id = MemberId(FastHash(1));
1343
1344        let message1 = create_test_message(owner_id, author_id);
1345        let message2 = create_test_message(owner_id, author_id);
1346
1347        let authorized_message1 = AuthorizedMessageV1::new(message1, &signing_key);
1348        let authorized_message2 = AuthorizedMessageV1::new(message2, &signing_key);
1349
1350        let messages = MessagesV1 {
1351            messages: vec![authorized_message1.clone(), authorized_message2.clone()],
1352            ..Default::default()
1353        };
1354
1355        let parent_state = ChatRoomStateV1::default();
1356        let parameters = ChatRoomParametersV1 {
1357            owner: signing_key.verifying_key(),
1358        };
1359
1360        let summary = messages.summarize(&parent_state, &parameters);
1361        assert_eq!(summary.message_ids.len(), 2);
1362        assert!(summary.message_ids.contains(&authorized_message1.id()));
1363        assert!(summary.message_ids.contains(&authorized_message2.id()));
1364        // Two messages against the default cap of 100: plenty of room left.
1365        assert_eq!(summary.horizon, RetentionHorizon::Open);
1366
1367        // Test empty messages
1368        let empty_messages = MessagesV1::default();
1369        let empty_summary = empty_messages.summarize(&parent_state, &parameters);
1370        assert!(empty_summary.message_ids.is_empty());
1371        assert_eq!(empty_summary.horizon, RetentionHorizon::Open);
1372    }
1373
1374    #[test]
1375    fn test_messages_delta() {
1376        let signing_key = SigningKey::generate(&mut OsRng);
1377        let owner_id = MemberId(FastHash(0));
1378        let author_id = MemberId(FastHash(1));
1379
1380        // Use distinct timestamps to ensure unique message IDs
1381        let base = SystemTime::now();
1382        let message1 = MessageV1 {
1383            room_owner: owner_id,
1384            author: author_id,
1385            time: base,
1386            content: RoomMessageBody::public("Message 1".to_string()),
1387        };
1388        let message2 = MessageV1 {
1389            room_owner: owner_id,
1390            author: author_id,
1391            time: base + Duration::from_millis(1),
1392            content: RoomMessageBody::public("Message 2".to_string()),
1393        };
1394        let message3 = MessageV1 {
1395            room_owner: owner_id,
1396            author: author_id,
1397            time: base + Duration::from_millis(2),
1398            content: RoomMessageBody::public("Message 3".to_string()),
1399        };
1400
1401        let authorized_message1 = AuthorizedMessageV1::new(message1, &signing_key);
1402        let authorized_message2 = AuthorizedMessageV1::new(message2, &signing_key);
1403        let authorized_message3 = AuthorizedMessageV1::new(message3, &signing_key);
1404
1405        let messages = MessagesV1 {
1406            messages: vec![
1407                authorized_message1.clone(),
1408                authorized_message2.clone(),
1409                authorized_message3.clone(),
1410            ],
1411            ..Default::default()
1412        };
1413
1414        let parent_state = ChatRoomStateV1::default();
1415        let parameters = ChatRoomParametersV1 {
1416            owner: signing_key.verifying_key(),
1417        };
1418
1419        // A receiver below its cap accepts anything, so these cases isolate the
1420        // id-set half of `delta` from the horizon half.
1421        let open_summary = |ids: &[MessageId]| MessagesSummary {
1422            message_ids: ids.iter().cloned().collect(),
1423            horizon: RetentionHorizon::Open,
1424        };
1425
1426        // Test with partial old summary
1427        let old_summary = open_summary(&[authorized_message1.id(), authorized_message2.id()]);
1428        let delta = messages
1429            .delta(&parent_state, &parameters, &old_summary)
1430            .unwrap();
1431        assert_eq!(delta.len(), 1);
1432        assert_eq!(delta[0], authorized_message3);
1433
1434        // Test with empty old summary
1435        let empty_summary = open_summary(&[]);
1436        let full_delta = messages
1437            .delta(&parent_state, &parameters, &empty_summary)
1438            .unwrap();
1439        assert_eq!(full_delta.len(), 3);
1440        assert_eq!(full_delta, messages.messages);
1441
1442        // Test with full old summary (no changes)
1443        let full_summary = open_summary(&[
1444            authorized_message1.id(),
1445            authorized_message2.id(),
1446            authorized_message3.id(),
1447        ]);
1448        let no_delta = messages.delta(&parent_state, &parameters, &full_summary);
1449        assert!(no_delta.is_none());
1450    }
1451
1452    #[test]
1453    fn test_messages_apply_delta() {
1454        // Setup
1455        let owner_signing_key = SigningKey::generate(&mut OsRng);
1456        let owner_verifying_key = owner_signing_key.verifying_key();
1457        let owner_id = MemberId::from(&owner_verifying_key);
1458
1459        let author_signing_key = SigningKey::generate(&mut OsRng);
1460        let author_verifying_key = author_signing_key.verifying_key();
1461        let author_id = MemberId::from(&author_verifying_key);
1462
1463        let mut parent_state = ChatRoomStateV1::default();
1464        parent_state.configuration.configuration.max_recent_messages = 3;
1465        parent_state.configuration.configuration.max_message_size = 100;
1466        parent_state.members.members = vec![crate::room_state::member::AuthorizedMember {
1467            member: crate::room_state::member::Member {
1468                owner_member_id: owner_id,
1469                invited_by: owner_id,
1470                member_vk: author_verifying_key,
1471            },
1472            signature: owner_signing_key.try_sign(&[0; 32]).unwrap(),
1473        }];
1474
1475        let parameters = ChatRoomParametersV1 {
1476            owner: owner_verifying_key,
1477        };
1478
1479        // Create messages
1480        let create_message = |time: SystemTime| {
1481            let message = MessageV1 {
1482                room_owner: owner_id,
1483                author: author_id,
1484                time,
1485                content: RoomMessageBody::public("Test message".to_string()),
1486            };
1487            AuthorizedMessageV1::new(message, &author_signing_key)
1488        };
1489
1490        let now = SystemTime::now();
1491        let message1 = create_message(now - Duration::from_secs(3));
1492        let message2 = create_message(now - Duration::from_secs(2));
1493        let message3 = create_message(now - Duration::from_secs(1));
1494        let message4 = create_message(now);
1495
1496        // Initial room_state with 2 messages
1497        let mut messages = MessagesV1 {
1498            messages: vec![message1.clone(), message2.clone()],
1499            ..Default::default()
1500        };
1501
1502        // Apply delta with 2 new messages
1503        let delta = vec![message3.clone(), message4.clone()];
1504        assert!(messages
1505            .apply_delta(&parent_state, &parameters, &Some(delta))
1506            .is_ok());
1507
1508        // Check results
1509        assert_eq!(
1510            messages.messages.len(),
1511            3,
1512            "Should have 3 messages after applying delta"
1513        );
1514        assert!(
1515            !messages.messages.contains(&message1),
1516            "Oldest message should be removed"
1517        );
1518        assert!(
1519            messages.messages.contains(&message2),
1520            "Second oldest message should be retained"
1521        );
1522        assert!(
1523            messages.messages.contains(&message3),
1524            "New message should be added"
1525        );
1526        assert!(
1527            messages.messages.contains(&message4),
1528            "Newest message should be added"
1529        );
1530
1531        // Apply delta with an older message
1532        let old_message = create_message(now - Duration::from_secs(4));
1533        let delta = vec![old_message.clone()];
1534        assert!(messages
1535            .apply_delta(&parent_state, &parameters, &Some(delta))
1536            .is_ok());
1537
1538        // Check results
1539        assert_eq!(messages.messages.len(), 3, "Should still have 3 messages");
1540        assert!(
1541            !messages.messages.contains(&old_message),
1542            "Older message should not be added"
1543        );
1544        assert!(
1545            messages.messages.contains(&message2),
1546            "Message2 should be retained"
1547        );
1548        assert!(
1549            messages.messages.contains(&message3),
1550            "Message3 should be retained"
1551        );
1552        assert!(
1553            messages.messages.contains(&message4),
1554            "Newest message should be retained"
1555        );
1556    }
1557
1558    #[test]
1559    fn test_oversized_message_filtered_by_apply_delta() {
1560        let owner_sk = SigningKey::generate(&mut OsRng);
1561        let owner_vk = owner_sk.verifying_key();
1562        let owner_id = MemberId::from(&owner_vk);
1563
1564        let author_sk = SigningKey::generate(&mut OsRng);
1565        let author_vk = author_sk.verifying_key();
1566        let author_id = MemberId::from(&author_vk);
1567
1568        let mut parent_state = ChatRoomStateV1::default();
1569        parent_state.configuration.configuration.max_message_size = 50;
1570        parent_state.configuration.configuration.max_recent_messages = 10;
1571        parent_state.members.members = vec![crate::room_state::member::AuthorizedMember {
1572            member: crate::room_state::member::Member {
1573                owner_member_id: owner_id,
1574                invited_by: owner_id,
1575                member_vk: author_vk,
1576            },
1577            signature: owner_sk.try_sign(&[0; 32]).unwrap(),
1578        }];
1579
1580        let parameters = ChatRoomParametersV1 { owner: owner_vk };
1581
1582        // Create a normal-sized message and an oversized message
1583        let small_msg = AuthorizedMessageV1::new(
1584            MessageV1 {
1585                room_owner: owner_id,
1586                author: author_id,
1587                time: SystemTime::now(),
1588                content: RoomMessageBody::public("short".to_string()),
1589            },
1590            &author_sk,
1591        );
1592        let big_msg = AuthorizedMessageV1::new(
1593            MessageV1 {
1594                room_owner: owner_id,
1595                author: author_id,
1596                time: SystemTime::now(),
1597                content: RoomMessageBody::public("x".repeat(100)),
1598            },
1599            &author_sk,
1600        );
1601
1602        assert!(small_msg.message.content.content_len() <= 50);
1603        assert!(big_msg.message.content.content_len() > 50);
1604
1605        let mut messages = MessagesV1::default();
1606        let delta = vec![small_msg.clone(), big_msg.clone()];
1607        assert!(messages
1608            .apply_delta(&parent_state, &parameters, &Some(delta))
1609            .is_ok());
1610
1611        assert_eq!(
1612            messages.messages.len(),
1613            1,
1614            "Only small message should survive"
1615        );
1616        assert!(messages.messages.contains(&small_msg));
1617        assert!(
1618            !messages.messages.contains(&big_msg),
1619            "Oversized message should be filtered"
1620        );
1621    }
1622
1623    #[test]
1624    fn test_message_author_preservation_across_users() {
1625        // Create two users
1626        let user1_sk = SigningKey::generate(&mut OsRng);
1627        let user1_vk = user1_sk.verifying_key();
1628        let user1_id = MemberId::from(&user1_vk);
1629
1630        let user2_sk = SigningKey::generate(&mut OsRng);
1631        let user2_vk = user2_sk.verifying_key();
1632        let user2_id = MemberId::from(&user2_vk);
1633
1634        let owner_sk = SigningKey::generate(&mut OsRng);
1635        let owner_vk = owner_sk.verifying_key();
1636        let owner_id = MemberId::from(&owner_vk);
1637
1638        println!("User1 ID: {}", user1_id);
1639        println!("User2 ID: {}", user2_id);
1640        println!("Owner ID: {}", owner_id);
1641
1642        // Create messages from different users
1643        let msg1 = MessageV1 {
1644            room_owner: owner_id,
1645            author: user1_id,
1646            content: RoomMessageBody::public("Message from user1".to_string()),
1647            time: SystemTime::now(),
1648        };
1649
1650        let msg2 = MessageV1 {
1651            room_owner: owner_id,
1652            author: user2_id,
1653            content: RoomMessageBody::public("Message from user2".to_string()),
1654            time: SystemTime::now() + Duration::from_secs(1),
1655        };
1656
1657        let auth_msg1 = AuthorizedMessageV1::new(msg1.clone(), &user1_sk);
1658        let auth_msg2 = AuthorizedMessageV1::new(msg2.clone(), &user2_sk);
1659
1660        // Create a messages state with both messages
1661        let messages = MessagesV1 {
1662            messages: vec![auth_msg1.clone(), auth_msg2.clone()],
1663            ..Default::default()
1664        };
1665
1666        // Verify authors are preserved
1667        assert_eq!(messages.messages.len(), 2);
1668
1669        let stored_msg1 = &messages.messages[0];
1670        let stored_msg2 = &messages.messages[1];
1671
1672        assert_eq!(
1673            stored_msg1.message.author, user1_id,
1674            "Message 1 author should be user1, but got {}",
1675            stored_msg1.message.author
1676        );
1677        assert_eq!(
1678            stored_msg2.message.author, user2_id,
1679            "Message 2 author should be user2, but got {}",
1680            stored_msg2.message.author
1681        );
1682
1683        // Test that author IDs are different
1684        assert_ne!(user1_id, user2_id, "User IDs should be different");
1685
1686        // Test Display implementation
1687        let user1_id_str = user1_id.to_string();
1688        let user2_id_str = user2_id.to_string();
1689
1690        println!("User1 ID string: {}", user1_id_str);
1691        println!("User2 ID string: {}", user2_id_str);
1692
1693        assert_ne!(
1694            user1_id_str, user2_id_str,
1695            "User ID strings should be different"
1696        );
1697    }
1698
1699    #[test]
1700    fn test_edit_action() {
1701        let signing_key = SigningKey::generate(&mut OsRng);
1702        let verifying_key = signing_key.verifying_key();
1703        let owner_id = MemberId::from(&verifying_key);
1704        let author_id = owner_id;
1705
1706        // Create original message
1707        let original_msg = MessageV1 {
1708            room_owner: owner_id,
1709            author: author_id,
1710            time: SystemTime::now(),
1711            content: RoomMessageBody::public("Original content".to_string()),
1712        };
1713        let auth_original = AuthorizedMessageV1::new(original_msg, &signing_key);
1714        let original_id = auth_original.id();
1715
1716        // Create edit action
1717        let edit_msg = MessageV1 {
1718            room_owner: owner_id,
1719            author: author_id,
1720            time: SystemTime::now() + Duration::from_secs(1),
1721            content: RoomMessageBody::edit(original_id.clone(), "Edited content".to_string()),
1722        };
1723        let auth_edit = AuthorizedMessageV1::new(edit_msg, &signing_key);
1724
1725        // Create messages state and rebuild
1726        let mut messages = MessagesV1 {
1727            messages: vec![auth_original.clone(), auth_edit],
1728            ..Default::default()
1729        };
1730        messages.rebuild_actions_state();
1731
1732        // Verify edit was applied
1733        assert!(messages.is_edited(&original_id));
1734        let effective = messages.effective_text(&auth_original);
1735        assert_eq!(effective, Some("Edited content".to_string()));
1736
1737        // Verify display_messages still shows the original message
1738        let display: Vec<_> = messages.display_messages().collect();
1739        assert_eq!(display.len(), 1);
1740    }
1741
1742    #[test]
1743    fn test_edit_by_non_author_ignored() {
1744        let owner_sk = SigningKey::generate(&mut OsRng);
1745        let owner_vk = owner_sk.verifying_key();
1746        let owner_id = MemberId::from(&owner_vk);
1747
1748        let other_sk = SigningKey::generate(&mut OsRng);
1749        let other_id = MemberId::from(&other_sk.verifying_key());
1750
1751        // Create message by owner
1752        let original_msg = MessageV1 {
1753            room_owner: owner_id,
1754            author: owner_id,
1755            time: SystemTime::now(),
1756            content: RoomMessageBody::public("Original content".to_string()),
1757        };
1758        let auth_original = AuthorizedMessageV1::new(original_msg, &owner_sk);
1759        let original_id = auth_original.id();
1760
1761        // Create edit action by OTHER user (should be ignored)
1762        let edit_msg = MessageV1 {
1763            room_owner: owner_id,
1764            author: other_id,
1765            time: SystemTime::now() + Duration::from_secs(1),
1766            content: RoomMessageBody::edit(original_id.clone(), "Hacked content".to_string()),
1767        };
1768        let auth_edit = AuthorizedMessageV1::new(edit_msg, &other_sk);
1769
1770        let mut messages = MessagesV1 {
1771            messages: vec![auth_original.clone(), auth_edit],
1772            ..Default::default()
1773        };
1774        messages.rebuild_actions_state();
1775
1776        // Edit should be ignored - original content preserved
1777        assert!(!messages.is_edited(&original_id));
1778        let effective = messages.effective_text(&auth_original);
1779        assert_eq!(effective, Some("Original content".to_string()));
1780    }
1781
1782    #[test]
1783    fn test_delete_action() {
1784        let signing_key = SigningKey::generate(&mut OsRng);
1785        let verifying_key = signing_key.verifying_key();
1786        let owner_id = MemberId::from(&verifying_key);
1787
1788        // Create original message
1789        let original_msg = MessageV1 {
1790            room_owner: owner_id,
1791            author: owner_id,
1792            time: SystemTime::now(),
1793            content: RoomMessageBody::public("Will be deleted".to_string()),
1794        };
1795        let auth_original = AuthorizedMessageV1::new(original_msg, &signing_key);
1796        let original_id = auth_original.id();
1797
1798        // Create delete action
1799        let delete_msg = MessageV1 {
1800            room_owner: owner_id,
1801            author: owner_id,
1802            time: SystemTime::now() + Duration::from_secs(1),
1803            content: RoomMessageBody::delete(original_id.clone()),
1804        };
1805        let auth_delete = AuthorizedMessageV1::new(delete_msg, &signing_key);
1806
1807        let mut messages = MessagesV1 {
1808            messages: vec![auth_original, auth_delete],
1809            ..Default::default()
1810        };
1811        messages.rebuild_actions_state();
1812
1813        // Verify message is deleted
1814        assert!(messages.is_deleted(&original_id));
1815
1816        // Verify display_messages excludes deleted message
1817        let display: Vec<_> = messages.display_messages().collect();
1818        assert_eq!(display.len(), 0);
1819    }
1820
1821    #[test]
1822    fn test_reaction_action() {
1823        let user1_sk = SigningKey::generate(&mut OsRng);
1824        let user1_id = MemberId::from(&user1_sk.verifying_key());
1825
1826        let user2_sk = SigningKey::generate(&mut OsRng);
1827        let user2_id = MemberId::from(&user2_sk.verifying_key());
1828
1829        let owner_id = user1_id;
1830
1831        // Create original message
1832        let original_msg = MessageV1 {
1833            room_owner: owner_id,
1834            author: user1_id,
1835            time: SystemTime::now(),
1836            content: RoomMessageBody::public("React to me!".to_string()),
1837        };
1838        let auth_original = AuthorizedMessageV1::new(original_msg, &user1_sk);
1839        let original_id = auth_original.id();
1840
1841        // Create reaction from user2
1842        let reaction_msg = MessageV1 {
1843            room_owner: owner_id,
1844            author: user2_id,
1845            time: SystemTime::now() + Duration::from_secs(1),
1846            content: RoomMessageBody::reaction(original_id.clone(), "👍".to_string()),
1847        };
1848        let auth_reaction = AuthorizedMessageV1::new(reaction_msg, &user2_sk);
1849
1850        // Create another reaction from user1
1851        let reaction_msg2 = MessageV1 {
1852            room_owner: owner_id,
1853            author: user1_id,
1854            time: SystemTime::now() + Duration::from_secs(2),
1855            content: RoomMessageBody::reaction(original_id.clone(), "👍".to_string()),
1856        };
1857        let auth_reaction2 = AuthorizedMessageV1::new(reaction_msg2, &user1_sk);
1858
1859        let mut messages = MessagesV1 {
1860            messages: vec![auth_original, auth_reaction, auth_reaction2],
1861            ..Default::default()
1862        };
1863        messages.rebuild_actions_state();
1864
1865        // Verify reactions
1866        let reactions = messages.reactions(&original_id).unwrap();
1867        let thumbs_up = reactions.get("👍").unwrap();
1868        assert_eq!(thumbs_up.len(), 2);
1869        assert!(thumbs_up.contains(&user1_id));
1870        assert!(thumbs_up.contains(&user2_id));
1871    }
1872
1873    #[test]
1874    fn test_remove_reaction_action() {
1875        let user_sk = SigningKey::generate(&mut OsRng);
1876        let user_id = MemberId::from(&user_sk.verifying_key());
1877        let owner_id = user_id;
1878
1879        // Create original message
1880        let original_msg = MessageV1 {
1881            room_owner: owner_id,
1882            author: user_id,
1883            time: SystemTime::now(),
1884            content: RoomMessageBody::public("Test message".to_string()),
1885        };
1886        let auth_original = AuthorizedMessageV1::new(original_msg, &user_sk);
1887        let original_id = auth_original.id();
1888
1889        // Add reaction
1890        let reaction_msg = MessageV1 {
1891            room_owner: owner_id,
1892            author: user_id,
1893            time: SystemTime::now() + Duration::from_secs(1),
1894            content: RoomMessageBody::reaction(original_id.clone(), "❤️".to_string()),
1895        };
1896        let auth_reaction = AuthorizedMessageV1::new(reaction_msg, &user_sk);
1897
1898        // Remove reaction
1899        let remove_msg = MessageV1 {
1900            room_owner: owner_id,
1901            author: user_id,
1902            time: SystemTime::now() + Duration::from_secs(2),
1903            content: RoomMessageBody::remove_reaction(original_id.clone(), "❤️".to_string()),
1904        };
1905        let auth_remove = AuthorizedMessageV1::new(remove_msg, &user_sk);
1906
1907        let mut messages = MessagesV1 {
1908            messages: vec![auth_original, auth_reaction, auth_remove],
1909            ..Default::default()
1910        };
1911        messages.rebuild_actions_state();
1912
1913        // Verify reaction was removed
1914        assert!(messages.reactions(&original_id).is_none());
1915    }
1916
1917    /// Swapping one reaction for another emits remove(old) + add(new) as a
1918    /// PAIR, and since freenet/river#512 removed the delegate round-trip that
1919    /// used to separate them, both are stamped from the same `Date.now()` —
1920    /// so they now essentially always share a millisecond. `MessageOrderKey`
1921    /// then breaks the tie by message id, which is a signature hash: the
1922    /// replay order is effectively random and differs between peers.
1923    ///
1924    /// That is only safe because `ACTION_TYPE_REMOVE_REACTION` is scoped to
1925    /// its own emoji, so the two actions touch disjoint map keys and commute.
1926    /// If `remove_reaction` is ever widened to "clear this actor's reaction on
1927    /// this target" — the natural reading of the one-reaction-per-user rule
1928    /// the UI enforces client-side — a reaction swap starts silently
1929    /// no-opping about half the time, on a hash comparison. This is what says
1930    /// so.
1931    #[test]
1932    fn a_same_millisecond_reaction_swap_converges_either_way() {
1933        let user_sk = SigningKey::generate(&mut OsRng);
1934        let user_id = MemberId::from(&user_sk.verifying_key());
1935        let owner_id = user_id;
1936
1937        let original = AuthorizedMessageV1::new(
1938            MessageV1 {
1939                room_owner: owner_id,
1940                author: user_id,
1941                time: SystemTime::UNIX_EPOCH,
1942                content: RoomMessageBody::public("React to me!".to_string()),
1943            },
1944            &user_sk,
1945        );
1946        let target = original.id();
1947
1948        let react = |emoji: &str, remove: bool, time: SystemTime| {
1949            let content = if remove {
1950                RoomMessageBody::remove_reaction(target.clone(), emoji.to_string())
1951            } else {
1952                RoomMessageBody::reaction(target.clone(), emoji.to_string())
1953            };
1954            AuthorizedMessageV1::new(
1955                MessageV1 {
1956                    room_owner: owner_id,
1957                    author: user_id,
1958                    time,
1959                    content,
1960                },
1961                &user_sk,
1962            )
1963        };
1964
1965        let first = react("👍", false, SystemTime::UNIX_EPOCH + Duration::from_secs(1));
1966        // The swap pair: identical timestamps, as the UI now produces them.
1967        let swap_at = SystemTime::UNIX_EPOCH + Duration::from_secs(2);
1968        let remove_old = react("👍", true, swap_at);
1969        let add_new = react("❤️", false, swap_at);
1970
1971        for (label, pair) in [
1972            ("remove first", vec![remove_old.clone(), add_new.clone()]),
1973            ("add first", vec![add_new, remove_old]),
1974        ] {
1975            let mut messages = MessagesV1 {
1976                messages: [vec![original.clone(), first.clone()], pair].concat(),
1977                ..Default::default()
1978            };
1979            let replay_order: Vec<_> = messages.messages.iter().map(|m| m.id()).collect();
1980            messages.rebuild_actions_state();
1981            // Premise: the replay really is in vector order, so the two
1982            // iterations of this loop exercise two DIFFERENT orders. If a
1983            // future refactor sorts inside `rebuild_actions_state`, this test
1984            // would otherwise degenerate into running one order twice and
1985            // stay green.
1986            assert_eq!(
1987                messages.messages.iter().map(|m| m.id()).collect::<Vec<_>>(),
1988                replay_order,
1989                "{label}: rebuild_actions_state must replay in vector order"
1990            );
1991
1992            let reactions = messages
1993                .reactions(&target)
1994                .unwrap_or_else(|| panic!("{label}: the swapped-in reaction must survive"));
1995            assert_eq!(
1996                reactions.get("❤️").map(|r| r.as_slice()),
1997                Some([user_id].as_slice()),
1998                "{label}: the new reaction must be present"
1999            );
2000            assert!(
2001                !reactions.contains_key("👍"),
2002                "{label}: the old reaction must be gone"
2003            );
2004        }
2005    }
2006
2007    #[test]
2008    fn test_action_on_deleted_message_ignored() {
2009        let signing_key = SigningKey::generate(&mut OsRng);
2010        let verifying_key = signing_key.verifying_key();
2011        let owner_id = MemberId::from(&verifying_key);
2012
2013        // Create original message
2014        let original_msg = MessageV1 {
2015            room_owner: owner_id,
2016            author: owner_id,
2017            time: SystemTime::now(),
2018            content: RoomMessageBody::public("Will be deleted".to_string()),
2019        };
2020        let auth_original = AuthorizedMessageV1::new(original_msg, &signing_key);
2021        let original_id = auth_original.id();
2022
2023        // Delete it
2024        let delete_msg = MessageV1 {
2025            room_owner: owner_id,
2026            author: owner_id,
2027            time: SystemTime::now() + Duration::from_secs(1),
2028            content: RoomMessageBody::delete(original_id.clone()),
2029        };
2030        let auth_delete = AuthorizedMessageV1::new(delete_msg, &signing_key);
2031
2032        // Try to edit deleted message (should be ignored)
2033        let edit_msg = MessageV1 {
2034            room_owner: owner_id,
2035            author: owner_id,
2036            time: SystemTime::now() + Duration::from_secs(2),
2037            content: RoomMessageBody::edit(original_id.clone(), "Too late!".to_string()),
2038        };
2039        let auth_edit = AuthorizedMessageV1::new(edit_msg, &signing_key);
2040
2041        let mut messages = MessagesV1 {
2042            messages: vec![auth_original, auth_delete, auth_edit],
2043            ..Default::default()
2044        };
2045        messages.rebuild_actions_state();
2046
2047        // Message should be deleted, edit should be ignored
2048        assert!(messages.is_deleted(&original_id));
2049        assert!(!messages.is_edited(&original_id));
2050    }
2051
2052    #[test]
2053    fn test_display_messages_filters_actions() {
2054        let signing_key = SigningKey::generate(&mut OsRng);
2055        let verifying_key = signing_key.verifying_key();
2056        let owner_id = MemberId::from(&verifying_key);
2057
2058        // Create regular message
2059        let msg1 = MessageV1 {
2060            room_owner: owner_id,
2061            author: owner_id,
2062            time: SystemTime::now(),
2063            content: RoomMessageBody::public("Hello".to_string()),
2064        };
2065        let auth_msg1 = AuthorizedMessageV1::new(msg1, &signing_key);
2066        let msg1_id = auth_msg1.id();
2067
2068        // Create reaction (action message)
2069        let reaction_msg = MessageV1 {
2070            room_owner: owner_id,
2071            author: owner_id,
2072            time: SystemTime::now() + Duration::from_secs(1),
2073            content: RoomMessageBody::reaction(msg1_id, "👍".to_string()),
2074        };
2075        let auth_reaction = AuthorizedMessageV1::new(reaction_msg, &signing_key);
2076
2077        // Create another regular message
2078        let msg2 = MessageV1 {
2079            room_owner: owner_id,
2080            author: owner_id,
2081            time: SystemTime::now() + Duration::from_secs(2),
2082            content: RoomMessageBody::public("World".to_string()),
2083        };
2084        let auth_msg2 = AuthorizedMessageV1::new(msg2, &signing_key);
2085
2086        let mut messages = MessagesV1 {
2087            messages: vec![auth_msg1, auth_reaction, auth_msg2],
2088            ..Default::default()
2089        };
2090        messages.rebuild_actions_state();
2091
2092        // display_messages should only return regular messages, not actions
2093        let display: Vec<_> = messages.display_messages().collect();
2094        assert_eq!(display.len(), 2);
2095        assert_eq!(
2096            display[0].message.content.as_public_string(),
2097            Some("Hello".to_string())
2098        );
2099        assert_eq!(
2100            display[1].message.content.as_public_string(),
2101            Some("World".to_string())
2102        );
2103    }
2104}
2105
2106#[cfg(test)]
2107mod measure_tests {
2108    use super::*;
2109
2110    /// Text samples crossing CBOR length-prefix boundaries (23/24, 255/256
2111    /// bytes) and mixing multi-byte UTF-8 (the HostFat report: chars < limit
2112    /// but encoded bytes > limit).
2113    fn samples() -> Vec<String> {
2114        vec![
2115            String::new(),
2116            "a".repeat(1),
2117            "a".repeat(23),
2118            "a".repeat(24),
2119            "a".repeat(255),
2120            "a".repeat(256),
2121            "a".repeat(997),
2122            "a".repeat(998),
2123            "a".repeat(1000),
2124            "é".repeat(400),  // 800 bytes, 400 chars
2125            "🎉".repeat(200), // 800 bytes, 200 chars
2126            format!("{}é🎉", "a".repeat(990)),
2127        ]
2128    }
2129
2130    fn target_id() -> MessageId {
2131        MessageId(FastHash(0x1234_5678_9abc_def0_u64 as i64))
2132    }
2133
2134    #[test]
2135    fn measure_text_matches_public_body() {
2136        for text in samples() {
2137            let body = RoomMessageBody::public(text.clone());
2138            assert_eq!(
2139                RoomMessageBody::measure_text(&text, false),
2140                body.content_len(),
2141                "text bytes={} chars={}",
2142                text.len(),
2143                text.chars().count()
2144            );
2145        }
2146    }
2147
2148    #[test]
2149    fn measure_reply_matches_reply_body() {
2150        let previews = ["", "short", &"préview🎉 ".repeat(10)];
2151        let authors = ["", "Alice", "HöstFat"];
2152        for text in samples() {
2153            for preview in previews {
2154                for author in authors {
2155                    let body = RoomMessageBody::reply(
2156                        text.clone(),
2157                        target_id(),
2158                        author.to_string(),
2159                        preview.to_string(),
2160                    );
2161                    assert_eq!(
2162                        RoomMessageBody::measure_reply(&text, target_id(), author, preview, false),
2163                        body.content_len(),
2164                        "text bytes={} author={:?} preview bytes={}",
2165                        text.len(),
2166                        author,
2167                        preview.len()
2168                    );
2169                }
2170            }
2171        }
2172    }
2173
2174    #[test]
2175    fn measure_edit_matches_edit_body() {
2176        for text in samples() {
2177            let body = RoomMessageBody::edit(target_id(), text.clone());
2178            let measured = RoomMessageBody::measure_edit(target_id(), &text, false);
2179            assert_eq!(measured, body.content_len(), "text bytes={}", text.len());
2180
2181            // Consistency alone let freenet/river#443 hide here for months:
2182            // this loop happily accepted a 1000-char edit measuring ~2070
2183            // bytes because it only compared the measure against the body.
2184            // Also assert MAGNITUDE, across every sample — which covers the
2185            // multi-byte UTF-8 and CBOR length-prefix boundary cases that the
2186            // ASCII-only #443 pins do not.
2187            assert!(
2188                measured <= text.len() + 80,
2189                "edit overhead must be a small constant: {measured} bytes for {} text bytes",
2190                text.len()
2191            );
2192        }
2193    }
2194
2195    /// Pins the bug class this API exists to prevent: raw text within the
2196    /// default 1000-byte limit whose ENCODED content exceeds it. The old UI
2197    /// gate compared `text.len()` and let these through; the contract then
2198    /// silently pruned them ("a message was lost").
2199    #[test]
2200    fn raw_text_gate_undercounts_encoded_size() {
2201        let max = 1000;
2202        let text = "a".repeat(998); // 998 chars -> 1007 encoded (raw + 9)
2203        assert!(text.len() <= max);
2204        assert!(RoomMessageBody::measure_text(&text, false) > max);
2205
2206        // A reply blows the budget far earlier because of embedded metadata.
2207        let reply_text = "a".repeat(900);
2208        assert!(reply_text.len() <= max);
2209        assert!(
2210            RoomMessageBody::measure_reply(
2211                &reply_text,
2212                target_id(),
2213                "Alice",
2214                &"p".repeat(100),
2215                false
2216            ) > max
2217        );
2218    }
2219
2220    /// Regression pin for freenet/river#443 at the level the UI gate uses.
2221    ///
2222    /// `ActionContentV1::payload` used to serialize as a CBOR array of
2223    /// integers, costing ~2.1 bytes per ASCII character against a plain
2224    /// message's ~1.01. Against the default 1000-byte limit that capped edits
2225    /// at ~467 characters while sends allowed ~991, so a message could be sent
2226    /// and then never edited.
2227    ///
2228    /// The fix makes edit cost **proportional-parity** with send: the overhead
2229    /// is now a small CONSTANT (CBOR framing for `action_type` / `target` /
2230    /// the nested `EditPayload`), not a per-character multiplier. This test
2231    /// pins that property, which is the one that actually prevents the bug
2232    /// class from scaling with message length.
2233    ///
2234    /// NOTE: a residual constant gap remains — see
2235    /// `edit_overhead_over_send_is_a_small_constant`. It is ~54 bytes, so the
2236    /// longest editable message (~946 chars) is still slightly shorter than
2237    /// the longest sendable one (~991). Closing that fully would mean either
2238    /// shrinking the send budget or another wire-format change, so it is
2239    /// deliberately left as a documented, bounded residual rather than
2240    /// silently fixed here.
2241    #[test]
2242    fn edit_cost_is_not_proportional_to_length() {
2243        for n in [100usize, 400, 900] {
2244            let text = "a".repeat(n);
2245            let overhead = RoomMessageBody::measure_edit(target_id(), &text, false) - n;
2246            assert!(
2247                overhead < 80,
2248                "edit overhead must be a small constant, got {overhead} bytes for {n} chars"
2249            );
2250        }
2251
2252        // The concrete user-facing win: a 900-char edit now fits the default
2253        // budget (it measured ~1866 bytes before the fix).
2254        let text = "a".repeat(900);
2255        assert!(RoomMessageBody::measure_edit(target_id(), &text, false) <= 1000);
2256
2257        // NOTE: deliberately no `private - public == ENCRYPTION_TAG_OVERHEAD`
2258        // assertion here — both sides funnel through `with_encryption_overhead`,
2259        // so it holds by construction for ANY encoding and would inflate this
2260        // test's apparent coverage. The real pin is
2261        // `private_bodies::measure_edit_matches_private_body`, which compares
2262        // against an actually-encrypted body.
2263    }
2264
2265    /// Pins the RESIDUAL of freenet/river#443 so it cannot silently grow back
2266    /// into a proportional cost. An edit of the same text costs a bounded
2267    /// constant more than sending it; if this constant creeps up, the
2268    /// send-but-cannot-edit window widens again.
2269    #[test]
2270    fn edit_overhead_over_send_is_a_small_constant() {
2271        let mut overheads = vec![];
2272        for n in [10usize, 100, 500, 900] {
2273            let text = "a".repeat(n);
2274            let send = RoomMessageBody::measure_text(&text, false);
2275            let edit = RoomMessageBody::measure_edit(target_id(), &text, false);
2276            assert!(
2277                edit > send,
2278                "an edit carries strictly more framing than a send"
2279            );
2280            overheads.push(edit - send);
2281        }
2282
2283        let max_overhead = *overheads.iter().max().expect("non-empty");
2284        assert!(
2285            max_overhead <= 60,
2286            "edit-over-send overhead must stay a small constant, got {overheads:?}"
2287        );
2288        // Constant, not growing with length: spread across sizes stays tight.
2289        let min_overhead = *overheads.iter().min().expect("non-empty");
2290        assert!(
2291            max_overhead - min_overhead <= 4,
2292            "edit-over-send overhead must not scale with length, got {overheads:?}"
2293        );
2294    }
2295
2296    #[cfg(feature = "ecies-randomized")]
2297    mod private_bodies {
2298        use super::*;
2299        // Only these tests use the content-type constants, so they are imported
2300        // here rather than in the parent module — otherwise they are dead
2301        // imports whenever `ecies-randomized` is off, which is the default.
2302        use crate::ecies::encrypt_with_symmetric_key;
2303        use crate::room_state::content::{
2304            CONTENT_TYPE_REPLY, CONTENT_TYPE_TEXT, REPLY_CONTENT_VERSION, TEXT_CONTENT_VERSION,
2305        };
2306
2307        const SECRET: [u8; 32] = [7u8; 32];
2308
2309        #[test]
2310        fn measure_text_matches_private_body() {
2311            for text in samples() {
2312                let content_bytes =
2313                    crate::room_state::content::TextContentV1::new(text.clone()).encode();
2314                let (ciphertext, nonce) = encrypt_with_symmetric_key(&SECRET, &content_bytes);
2315                let body = RoomMessageBody::private(
2316                    CONTENT_TYPE_TEXT,
2317                    TEXT_CONTENT_VERSION,
2318                    ciphertext,
2319                    nonce,
2320                    1,
2321                );
2322                assert_eq!(
2323                    RoomMessageBody::measure_text(&text, true),
2324                    body.content_len(),
2325                    "text bytes={}",
2326                    text.len()
2327                );
2328            }
2329        }
2330
2331        #[test]
2332        fn measure_reply_matches_private_body() {
2333            for text in samples() {
2334                let reply = crate::room_state::content::ReplyContentV1::new(
2335                    text.clone(),
2336                    target_id(),
2337                    "Alice".to_string(),
2338                    "some preview".to_string(),
2339                );
2340                let (ciphertext, nonce) = encrypt_with_symmetric_key(&SECRET, &reply.encode());
2341                let body = RoomMessageBody::private(
2342                    CONTENT_TYPE_REPLY,
2343                    REPLY_CONTENT_VERSION,
2344                    ciphertext,
2345                    nonce,
2346                    1,
2347                );
2348                assert_eq!(
2349                    RoomMessageBody::measure_reply(
2350                        &text,
2351                        target_id(),
2352                        "Alice",
2353                        "some preview",
2354                        true
2355                    ),
2356                    body.content_len(),
2357                    "text bytes={}",
2358                    text.len()
2359                );
2360            }
2361        }
2362
2363        #[test]
2364        fn measure_edit_matches_private_body() {
2365            for text in samples() {
2366                let action =
2367                    crate::room_state::content::ActionContentV1::edit(target_id(), text.clone());
2368                let (ciphertext, nonce) = encrypt_with_symmetric_key(&SECRET, &action.encode());
2369                let body = RoomMessageBody::private_action(ciphertext, nonce, 1);
2370                assert_eq!(
2371                    RoomMessageBody::measure_edit(target_id(), &text, true),
2372                    body.content_len(),
2373                    "text bytes={}",
2374                    text.len()
2375                );
2376            }
2377        }
2378    }
2379}