mumble_server_runtime_shard/
emit.rs

1//! Translating a composed transition into Mumble control messages.
2//!
3//! [`mod@crate::plan`] deliberately stops at view mutations: a [`PlanOp`] says *what
4//! changes*, never *which frame carries it*. This module is the only place in
5//! the crate that knows both vocabularies, and keeping it pure - no socket, no
6//! queue - is what lets the ordering rules be tested against the message stream
7//! itself.
8//!
9//! # The one constraint the plan cannot express
10//!
11//! A connection must be introduced to itself before it is introduced to anyone
12//! else, because `ServerSync` makes the client look its own session up. The plan
13//! has no notion of "self" - that is a connection notion, not a view notion - so
14//! the rule is applied here, by reordering **inside a run of consecutive
15//! `AddUser` operations** and never across the whole sequence. Hoisting the self
16//! user to the front would place it before the `CreateChannel` of its own
17//! channel, breaking one rule to satisfy another. The planner groups user
18//! additions together, so a run is exactly the set among which order is free.
19
20use mumble_server_runtime_protocol::ControlMessage;
21use mumble_server_runtime_protocol::messages::tcp;
22
23use crate::ids::{ActionKey, ChannelId, SessionId};
24use crate::plan::{ChannelPatch, PlanOp, UserPatch};
25use crate::reply::Word;
26use crate::view::{Action, Actions, Channel, User};
27
28/// Effective-permission bits, as the Mumble client understands them.
29///
30/// REF: references/mumble/src/ACL.h : `enum ChanACL::Perm`.
31pub mod perm {
32    pub const TRAVERSE: u32 = 0x2;
33    pub const ENTER: u32 = 0x4;
34    pub const SPEAK: u32 = 0x8;
35    pub const WHISPER: u32 = 0x100;
36    pub const TEXT_MESSAGE: u32 = 0x200;
37
38    /// What a client is told it may do where nothing forbids it.
39    ///
40    /// Deliberately excludes channel and administration rights: the tree is
41    /// rendered by a flavor and nothing a client sends can edit it, so
42    /// advertising those bits would only put buttons in the UI that answer with
43    /// `PermissionDenied`.
44    pub const DEFAULT: u32 = TRAVERSE | ENTER | SPEAK | WHISPER | TEXT_MESSAGE;
45}
46
47/// What a connection may do in a channel it can see, derived from the render.
48///
49/// There is no permission model to consult: the flavor renders a tree, and what
50/// it says about a channel's accessibility is `can_enter` and `can_text`.
51/// Deriving the answer from those is what keeps the reply honest for the
52/// generation it was asked about, rather than replaying a cache nothing
53/// invalidates (spec 16.16).
54///
55/// `TRAVERSE` is unconditional here because the question is only ever asked
56/// about a channel the connection already observes: it has traversed it by
57/// definition.
58#[must_use]
59pub fn permissions_of(channel: &Channel) -> u32 {
60    let mut permissions = perm::DEFAULT;
61    if !channel.can_enter {
62        permissions &= !perm::ENTER;
63    }
64    if !channel.can_text {
65        permissions &= !perm::TEXT_MESSAGE;
66    }
67    permissions
68}
69
70/// The reply to a client's `PermissionQuery` about one visible channel.
71#[must_use]
72pub fn permission_query(channel: &Channel) -> ControlMessage {
73    ControlMessage::PermissionQuery(tcp::PermissionQuery {
74        channel_id: Some(channel.id.0),
75        permissions: Some(permissions_of(channel)),
76        // A flush would tell the client to drop what it knows about **every**
77        // channel. This answers one question about one channel.
78        //
79        // REF: references/mumble/src/mumble/Messages.cpp : `msgPermissionQuery`
80        //   zeroes every channel's permissions when `flush()` is set.
81        flush: Some(false),
82    })
83}
84
85/// The reply to a client's `UserStats` about **somebody else** it can see.
86///
87/// It names the session and stops there. Everything the reference server puts in
88/// this message - certificate chain, client version, IP address, packet
89/// counters, connected and idle times - is either something this runtime does
90/// not know or something spec 16.17 forbids disclosing without an explicit
91/// authorization no flavor can currently express. An empty information window is
92/// the honest rendering of "the server publishes nothing about this user".
93///
94/// The requester's own statistics are a different question, answered where the
95/// transport lives rather than here.
96///
97/// REF: references/mumble/src/murmur/Messages.cpp : `msgUserStats` gates the
98///   certificates, version and address behind `extend` (self, or Ban at the
99///   root) and the counters behind `local`, and always answers with the session.
100#[must_use]
101pub fn user_stats(session: SessionId) -> ControlMessage {
102    ControlMessage::UserStats(tcp::UserStats {
103        session: Some(session.0),
104        ..Default::default()
105    })
106}
107
108/// Spell what a flavor said to one connection.
109///
110/// Speech carries no actor, which is precisely what makes the client attribute
111/// it to the server, and it names the recipient's session, which is what makes
112/// the client file it as addressed to them rather than as an announcement.
113///
114/// REF: references/mumble/src/mumble/Messages.cpp : `msgTextMessage` resolves
115///   the actor and falls back to `tr("Server", "message from")` when there is
116///   none.
117/// REF: references/mumble/src/murmur/RPC.cpp : `Server::sendTextMessage` adds
118///   the recipient's session when the message is aimed at one user.
119/// REF: references/vendored/Mumble.proto : `PermissionDenied.DenyType.Text`
120///   means "denied for another reason, see the reason field".
121#[must_use]
122pub fn spoken(to: SessionId, word: &Word) -> ControlMessage {
123    match word {
124        Word::Say(text) => ControlMessage::TextMessage(tcp::TextMessage {
125            session: vec![to.0],
126            message: text.clone(),
127            ..Default::default()
128        }),
129        Word::Refuse(reason) => ControlMessage::PermissionDenied(tcp::PermissionDenied {
130            session: Some(to.0),
131            reason: Some(reason.clone()),
132            r#type: Some(i32::from(tcp::permission_denied::DenyType::Text)),
133            ..Default::default()
134        }),
135    }
136}
137
138/// Where a text message is aimed, in the wire's vocabulary.
139///
140/// Exactly one target, because that is all the official client ever sends: the
141/// chat bar names one channel, the tree menu names one root, and a private
142/// message names one session. Accepting a mix would mean inventing a fan-out
143/// nobody asked for, so it is refused where the wire is parsed (R6).
144///
145/// REF: references/mumble/src/mumble/ServerHandler.cpp :
146///   `sendUserTextMessage` adds one session, `sendChannelTextMessage` adds one
147///   `channel_id` **or** one `tree_id`.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum TextTarget {
150    Session(SessionId),
151    Channel(ChannelId),
152    Tree(ChannelId),
153}
154
155/// Carry one connection's words to one recipient.
156///
157/// Which of the three lists is filled is not decoration: it is what the client
158/// prints in front of the line, and whether it files the message as private. The
159/// identifier put there is the one the **recipient** holds, never the one the
160/// sender named, because the two need not be the same thing here.
161///
162/// `actor` is `None` for the server's own voice, which is what makes the client
163/// attribute it to the server rather than to a user it would have to know.
164///
165/// REF: references/mumble/src/mumble/Messages.cpp : `msgTextMessage` labels the
166///   entry Tree, Channel or Private from whichever list is non-empty, and falls
167///   back to `tr("Server", "message from")` when there is no actor.
168/// REF: references/mumble/src/murmur/Messages.cpp : `msgTextMessage` stamps the
169///   actor itself and forwards one identical message to each recipient.
170#[must_use]
171pub fn relayed(actor: Option<SessionId>, to: TextTarget, text: &str) -> ControlMessage {
172    let mut message = tcp::TextMessage {
173        actor: actor.map(|session| session.0),
174        message: text.to_owned(),
175        ..Default::default()
176    };
177    match to {
178        TextTarget::Session(session) => message.session = vec![session.0],
179        TextTarget::Channel(channel) => message.channel_id = vec![channel.0],
180        TextTarget::Tree(channel) => message.tree_id = vec![channel.0],
181    }
182    ControlMessage::TextMessage(message)
183}
184
185/// Refuse something a connection asked to do in a channel it can see.
186///
187/// Distinct from the flavor's own [`Word::Refuse`]: this one names the
188/// permission and the channel, so the client can say which right was missing
189/// instead of printing a sentence. Only ever sent about a channel the connection
190/// already holds, since naming any other would answer a question it did not get
191/// to ask.
192///
193/// REF: references/mumble/src/murmur/Messages.cpp : the `PERM_DENIED` macro sets
194///   `permission`, `channel_id`, `session` and `DenyType::Permission`.
195#[must_use]
196pub fn denied_permission(
197    session: SessionId,
198    channel: ChannelId,
199    permission: u32,
200) -> ControlMessage {
201    ControlMessage::PermissionDenied(tcp::PermissionDenied {
202        permission: Some(permission),
203        channel_id: Some(channel.0),
204        session: Some(session.0),
205        r#type: Some(i32::from(tcp::permission_denied::DenyType::Permission)),
206        ..Default::default()
207    })
208}
209
210/// The wire name of an action key.
211///
212/// The client stores this string verbatim and hands it back on invocation, so it
213/// is the only thing that has to survive the round trip. Decimal because it is
214/// the shortest form that reads back unambiguously.
215///
216/// REF: references/mumble/src/mumble/Messages.cpp : `msgContextActionModify`
217///   stores `msg.action()` in the menu entry's data.
218/// REF: references/mumble/src/mumble/MainWindow.cpp : `context_triggered` sends
219///   that same data back as `ContextAction.action`.
220#[must_use]
221pub fn action_name(key: ActionKey) -> String {
222    key.0.to_string()
223}
224
225/// Read back what a client echoed, refusing anything this server did not write.
226#[must_use]
227pub fn action_key(name: &str) -> Option<ActionKey> {
228    name.parse::<u64>().ok().map(ActionKey)
229}
230
231/// What one connection must be told so its menu matches `fresh`.
232///
233/// A relabelled action is withdrawn and offered again rather than offered twice:
234/// the client builds a **new** menu entry on every `Add` and does not look for
235/// an existing one, so a second `Add` on a live key would leave the user with
236/// two buttons doing the same thing.
237///
238/// Withdrawals come first for the same reason: a rename must not race its own
239/// removal.
240///
241/// REF: references/mumble/src/mumble/Messages.cpp : `msgContextActionModify`
242///   allocates `new QAction` per `Add` and appends it to the context lists;
243///   `removeContextAction` deletes every entry whose data matches.
244#[must_use]
245pub fn actions(sent: &Actions, fresh: &Actions) -> Vec<ControlMessage> {
246    let mut withdrawn: Vec<ControlMessage> = Vec::new();
247    let mut offered: Vec<ControlMessage> = Vec::new();
248
249    for (key, action) in sent {
250        if fresh.get(key) != Some(action) {
251            withdrawn.push(withdraw_action(*key));
252        }
253    }
254    for (key, action) in fresh {
255        if sent.get(key) != Some(action) {
256            offered.push(offer_action(action));
257        }
258    }
259
260    withdrawn.extend(offered);
261    withdrawn
262}
263
264fn offer_action(action: &Action) -> ControlMessage {
265    ControlMessage::ContextActionModify(tcp::ContextActionModify {
266        action: action_name(action.key),
267        text: Some(action.text.clone()),
268        context: Some(action.on.bits()),
269        operation: Some(i32::from(tcp::context_action_modify::Operation::Add)),
270    })
271}
272
273fn withdraw_action(key: ActionKey) -> ControlMessage {
274    ControlMessage::ContextActionModify(tcp::ContextActionModify {
275        action: action_name(key),
276        // A removal names the action and nothing else: the client matches on the
277        // identifier alone.
278        text: None,
279        context: None,
280        operation: Some(i32::from(tcp::context_action_modify::Operation::Remove)),
281    })
282}
283
284/// Translate a composed transition into ordered control messages.
285///
286/// `self_session` is the connection's own session, used only for the
287/// introduce-yourself-first rule.
288#[must_use]
289pub fn emit(ops: &[PlanOp], self_session: SessionId) -> Vec<ControlMessage> {
290    self_first_within_user_additions(ops, self_session)
291        .into_iter()
292        .map(emit_op)
293        .collect()
294}
295
296/// Reorder so that, inside each run of consecutive `AddUser` operations, the
297/// connection introduces itself first.
298fn self_first_within_user_additions(ops: &[PlanOp], self_session: SessionId) -> Vec<&PlanOp> {
299    let mut ordered: Vec<&PlanOp> = Vec::with_capacity(ops.len());
300    let mut index = 0;
301
302    while index < ops.len() {
303        let Some(op) = ops.get(index) else { break };
304        if !matches!(op, PlanOp::AddUser(_)) {
305            ordered.push(op);
306            index += 1;
307            continue;
308        }
309
310        let start = index;
311        while ops
312            .get(index)
313            .is_some_and(|op| matches!(op, PlanOp::AddUser(_)))
314        {
315            index += 1;
316        }
317        let Some(run) = ops.get(start..index) else {
318            break;
319        };
320
321        // Stable within each group, so a plan that never adds the self user
322        // passes through completely unchanged.
323        ordered.extend(run.iter().filter(|op| is_self_addition(op, self_session)));
324        ordered.extend(run.iter().filter(|op| !is_self_addition(op, self_session)));
325    }
326
327    ordered
328}
329
330fn is_self_addition(op: &PlanOp, self_session: SessionId) -> bool {
331    matches!(op, PlanOp::AddUser(user) if user.session == self_session)
332}
333
334/// No wildcard arm: adding a [`PlanOp`] variant must break this build rather
335/// than silently produce a transition that omits it.
336fn emit_op(op: &PlanOp) -> ControlMessage {
337    match op {
338        PlanOp::CreateChannel(channel) => ControlMessage::ChannelState(created_channel(channel)),
339        PlanOp::UpdateChannel(patch) => ControlMessage::ChannelState(patched_channel(patch)),
340        PlanOp::RemoveChannel(channel) => ControlMessage::ChannelRemove(tcp::ChannelRemove {
341            channel_id: channel.0,
342        }),
343        PlanOp::AddUser(user) => ControlMessage::UserState(added_user(user)),
344        PlanOp::UpdateUser(patch) => ControlMessage::UserState(patched_user(patch)),
345        PlanOp::MoveUser { session, channel } => ControlMessage::UserState(tcp::UserState {
346            session: Some(session.0),
347            channel_id: Some(channel.0),
348            ..Default::default()
349        }),
350        PlanOp::RemoveUser(session) => ControlMessage::UserRemove(tcp::UserRemove {
351            session: session.0,
352            ..Default::default()
353        }),
354    }
355}
356
357/// A newly created channel, sent whole.
358///
359/// The root carries no `parent`: the client refuses to create a channel without
360/// one, and the root already exists on its side, so the message is an update to
361/// something it has. A channel parented to itself would be a cycle.
362///
363/// REF: references/mumble/src/mumble/Messages.cpp : `msgChannelState` creates a
364///   channel only `if (p && msg.has_name())`, and rejects a move into itself.
365fn created_channel(channel: &Channel) -> tcp::ChannelState {
366    let parent = (channel.id != ChannelId::ROOT).then_some(channel.parent.0);
367
368    tcp::ChannelState {
369        channel_id: Some(channel.id.0),
370        parent,
371        name: Some(channel.name.clone()),
372        position: Some(channel.position),
373        temporary: Some(false),
374        can_enter: Some(channel.can_enter),
375        links: channel.links.iter().map(|id| id.0).collect(),
376        ..Default::default()
377    }
378}
379
380/// A channel change, sent as a sparse `ChannelState`.
381///
382/// Links ride as `links_add`/`links_remove` rather than as `links`. The client
383/// treats a non-empty `links` list as a full replacement and ignores an empty
384/// one entirely, so incremental sets are both cheaper and the only way to
385/// express "unlink the last one".
386///
387/// REF: references/mumble/src/mumble/Messages.cpp : `msgChannelState` handles
388///   `links` under `if (msg.links_size())`, then `links_remove` and `links_add`
389///   in their own independent blocks.
390fn patched_channel(patch: &ChannelPatch) -> tcp::ChannelState {
391    tcp::ChannelState {
392        channel_id: Some(patch.id.0),
393        parent: patch.parent.map(|id| id.0),
394        name: patch.name.clone(),
395        position: patch.position,
396        can_enter: patch.can_enter,
397        links_add: patch.links_added.iter().map(|id| id.0).collect(),
398        links_remove: patch.links_removed.iter().map(|id| id.0).collect(),
399        ..Default::default()
400    }
401}
402
403/// A newly visible user, sent whole.
404///
405/// `channel_id` is always set, including for the root: Murmur omits it there,
406/// but being explicit removes a class of bug where a user silently lands in the
407/// root because a field was left out.
408fn added_user(user: &User) -> tcp::UserState {
409    let flags = user.flags;
410    tcp::UserState {
411        session: Some(user.session.0),
412        name: Some(user.name.clone()),
413        channel_id: Some(user.channel.0),
414        mute: Some(flags.mute),
415        deaf: Some(flags.deaf),
416        suppress: Some(flags.suppress),
417        self_mute: Some(flags.self_mute),
418        self_deaf: Some(flags.self_deaf),
419        priority_speaker: Some(flags.priority_speaker),
420        recording: Some(flags.recording),
421        ..Default::default()
422    }
423}
424
425/// A user change, sent as a sparse `UserState`.
426fn patched_user(patch: &UserPatch) -> tcp::UserState {
427    let flags = patch.flags.unwrap_or_default();
428    let set = patch.flags.is_some();
429
430    tcp::UserState {
431        session: Some(patch.session.0),
432        name: patch.name.clone(),
433        mute: set.then_some(flags.mute),
434        deaf: set.then_some(flags.deaf),
435        suppress: set.then_some(flags.suppress),
436        self_mute: set.then_some(flags.self_mute),
437        self_deaf: set.then_some(flags.self_deaf),
438        priority_speaker: set.then_some(flags.priority_speaker),
439        recording: set.then_some(flags.recording),
440        ..Default::default()
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    #![allow(clippy::expect_used)]
447
448    use std::collections::BTreeSet;
449
450    use super::*;
451    use crate::ids::{ChannelKey, ConnectionId, Occupant};
452    use crate::scope::Scope;
453    use crate::view::On;
454    use crate::view::UserFlags;
455
456    fn channel(id: u32, parent: u32) -> Channel {
457        Channel {
458            key: ChannelKey(u64::from(id)),
459            id: ChannelId(id),
460            parent: ChannelId(parent),
461            scope: Scope::ROOT,
462            name: format!("channel-{id}"),
463            position: 0,
464            can_enter: true,
465            can_text: true,
466            links: BTreeSet::new(),
467        }
468    }
469
470    fn user(session: u32, channel: u32) -> User {
471        User {
472            occupant: Occupant::Connection(ConnectionId(u64::from(session))),
473            session: SessionId(session),
474            channel: ChannelId(channel),
475            scope: Scope::ROOT,
476            name: format!("user-{session}"),
477            flags: UserFlags::default(),
478        }
479    }
480
481    fn user_sessions(messages: &[ControlMessage]) -> Vec<u32> {
482        messages
483            .iter()
484            .filter_map(|message| match message {
485                ControlMessage::UserState(state) => state.session,
486                _ => None,
487            })
488            .collect()
489    }
490
491    #[test]
492    fn a_channel_nobody_may_enter_is_advertised_without_the_enter_bit() {
493        let mut open = channel(1, 0);
494        assert_eq!(permissions_of(&open), perm::DEFAULT);
495
496        open.can_enter = false;
497        let closed = permissions_of(&open);
498        assert_eq!(
499            closed & perm::ENTER,
500            0,
501            "the client must not offer to enter"
502        );
503        assert_eq!(
504            closed & perm::TRAVERSE,
505            perm::TRAVERSE,
506            "a channel it can see is a channel it has traversed"
507        );
508    }
509
510    #[test]
511    fn a_read_only_channel_is_advertised_without_the_text_bit() {
512        let mut quiet = channel(1, 0);
513        quiet.can_text = false;
514
515        let permissions = permissions_of(&quiet);
516        assert_eq!(
517            permissions & perm::TEXT_MESSAGE,
518            0,
519            "the client must grey its chat box out rather than be refused later"
520        );
521        assert_eq!(
522            permissions & perm::SPEAK,
523            perm::SPEAK,
524            "being unable to write is not being unable to talk"
525        );
526    }
527
528    fn offered(key: u64, text: &str, on: On) -> (ActionKey, Action) {
529        let key = ActionKey(key);
530        (
531            key,
532            Action {
533                key,
534                text: text.to_owned(),
535                on,
536            },
537        )
538    }
539
540    fn modifications(messages: &[ControlMessage]) -> Vec<(String, Option<String>, i32)> {
541        messages
542            .iter()
543            .map(|message| match message {
544                ControlMessage::ContextActionModify(modify) => (
545                    modify.action.clone(),
546                    modify.text.clone(),
547                    modify.operation.unwrap_or_default(),
548                ),
549                other => panic!("expected a context action, got {other:?}"),
550            })
551            .collect()
552    }
553
554    const ADD: i32 = tcp::context_action_modify::Operation::Add as i32;
555    const REMOVE: i32 = tcp::context_action_modify::Operation::Remove as i32;
556
557    #[test]
558    fn an_unchanged_offer_says_nothing() {
559        let fresh: Actions = [offered(1, "Kick", On::USER)].into_iter().collect();
560        assert!(
561            actions(&fresh.clone(), &fresh).is_empty(),
562            "a menu that did not move must cost nothing"
563        );
564    }
565
566    #[test]
567    fn a_new_action_is_offered_and_a_dropped_one_withdrawn() {
568        let sent: Actions = [offered(1, "Kick", On::USER)].into_iter().collect();
569        let fresh: Actions = [offered(2, "Start", On::SERVER)].into_iter().collect();
570
571        assert_eq!(
572            modifications(&actions(&sent, &fresh)),
573            vec![
574                ("1".to_owned(), None, REMOVE),
575                ("2".to_owned(), Some("Start".to_owned()), ADD),
576            ],
577            "withdrawals come first, so a rename cannot race its own removal"
578        );
579    }
580
581    #[test]
582    fn a_relabelled_action_is_withdrawn_before_being_offered_again() {
583        // The client builds a NEW menu entry per Add and never looks for an
584        // existing one, so an Add alone would leave two buttons doing the same
585        // thing.
586        let sent: Actions = [offered(1, "Join", On::SERVER)].into_iter().collect();
587        let fresh: Actions = [offered(1, "Join the arena", On::SERVER)]
588            .into_iter()
589            .collect();
590
591        assert_eq!(
592            modifications(&actions(&sent, &fresh)),
593            vec![
594                ("1".to_owned(), None, REMOVE),
595                ("1".to_owned(), Some("Join the arena".to_owned()), ADD),
596            ]
597        );
598    }
599
600    #[test]
601    fn a_place_change_travels_like_a_relabelling() {
602        let sent: Actions = [offered(1, "Poke", On::USER)].into_iter().collect();
603        let fresh: Actions = [offered(1, "Poke", On::USER.and(On::CHANNEL))]
604            .into_iter()
605            .collect();
606
607        let messages = actions(&sent, &fresh);
608        assert_eq!(messages.len(), 2, "{messages:?}");
609        match messages.last() {
610            Some(ControlMessage::ContextActionModify(modify)) => assert_eq!(
611                modify.context,
612                Some(On::USER.and(On::CHANNEL).bits()),
613                "the offer must carry every place it is offered in"
614            ),
615            other => panic!("expected an offer, got {other:?}"),
616        }
617    }
618
619    #[test]
620    fn an_action_name_reads_back_and_nothing_else_does() {
621        assert_eq!(action_key(&action_name(ActionKey(7))), Some(ActionKey(7)));
622        assert_eq!(
623            action_key(&action_name(ActionKey(u64::MAX))),
624            Some(ActionKey(u64::MAX))
625        );
626        for hostile in ["", "1;drop", "-1", " 1", "0x1", "99999999999999999999999"] {
627            assert_eq!(
628                action_key(hostile),
629                None,
630                "{hostile:?} is not a name this server writes"
631            );
632        }
633    }
634
635    #[test]
636    fn a_permission_answer_never_flushes_the_client_s_cache() {
637        let ControlMessage::PermissionQuery(answer) = permission_query(&channel(4, 0)) else {
638            panic!("expected a PermissionQuery");
639        };
640        assert_eq!(answer.channel_id, Some(4));
641        assert_eq!(answer.permissions, Some(perm::DEFAULT));
642        assert_eq!(
643            answer.flush,
644            Some(false),
645            "answering one question must not invalidate every other channel"
646        );
647    }
648
649    #[test]
650    fn stats_about_somebody_else_carry_the_session_and_nothing_more() {
651        let ControlMessage::UserStats(answer) = user_stats(SessionId(7)) else {
652            panic!("expected a UserStats");
653        };
654        assert_eq!(answer.session, Some(7));
655        assert!(answer.certificates.is_empty(), "no certificate ever leaves");
656        assert_eq!(answer.address, None, "no address ever leaves");
657        assert_eq!(answer.version, None);
658        assert_eq!(answer.from_client, None, "no counters about a third party");
659        assert_eq!(
660            answer.onlinesecs, None,
661            "no connection time about a third party"
662        );
663    }
664
665    #[test]
666    fn the_self_user_is_introduced_before_the_others() {
667        let messages = emit(
668            &[
669                PlanOp::AddUser(user(7, 0)),
670                PlanOp::AddUser(user(9, 0)),
671                PlanOp::AddUser(user(3, 0)),
672            ],
673            SessionId(3),
674        );
675        assert_eq!(user_sessions(&messages), vec![3, 7, 9]);
676    }
677
678    #[test]
679    fn the_self_user_is_not_hoisted_past_the_channel_it_lives_in() {
680        let messages = emit(
681            &[
682                PlanOp::CreateChannel(channel(0, 0)),
683                PlanOp::CreateChannel(channel(1, 0)),
684                PlanOp::AddUser(user(7, 1)),
685                PlanOp::AddUser(user(3, 1)),
686            ],
687            SessionId(3),
688        );
689
690        let kinds: Vec<&str> = messages
691            .iter()
692            .map(|message| match message {
693                ControlMessage::ChannelState(_) => "channel",
694                ControlMessage::UserState(_) => "user",
695                _ => "other",
696            })
697            .collect();
698        assert_eq!(kinds, vec!["channel", "channel", "user", "user"]);
699    }
700
701    #[test]
702    fn a_plan_without_the_self_user_passes_through_unchanged() {
703        let ops = [PlanOp::AddUser(user(7, 0)), PlanOp::AddUser(user(9, 0))];
704        let messages = emit(&ops, SessionId(42));
705        assert_eq!(user_sessions(&messages), vec![7, 9]);
706    }
707
708    #[test]
709    fn the_root_channel_carries_no_parent() {
710        let messages = emit(&[PlanOp::CreateChannel(channel(0, 0))], SessionId(1));
711        let ControlMessage::ChannelState(state) = &messages[0] else {
712            panic!("expected a ChannelState");
713        };
714        assert_eq!(state.parent, None, "a self-parented root is a cycle");
715        assert_eq!(state.channel_id, Some(0));
716    }
717
718    #[test]
719    fn a_child_channel_carries_its_parent_and_name() {
720        // The client refuses to create a channel that has no parent or no name,
721        // so both fields are load-bearing rather than cosmetic.
722        let messages = emit(&[PlanOp::CreateChannel(channel(4, 1))], SessionId(1));
723        let ControlMessage::ChannelState(state) = &messages[0] else {
724            panic!("expected a ChannelState");
725        };
726        assert_eq!(state.parent, Some(1));
727        assert_eq!(state.name.as_deref(), Some("channel-4"));
728    }
729
730    #[test]
731    fn link_changes_ride_as_incremental_lists() {
732        let patch = ChannelPatch {
733            id: ChannelId(1),
734            parent: None,
735            name: None,
736            position: None,
737            can_enter: None,
738            links_added: BTreeSet::from([ChannelId(4)]),
739            links_removed: BTreeSet::from([ChannelId(2), ChannelId(3)]),
740        };
741        let messages = emit(&[PlanOp::UpdateChannel(patch)], SessionId(1));
742        let ControlMessage::ChannelState(state) = &messages[0] else {
743            panic!("expected a ChannelState");
744        };
745
746        assert_eq!(state.links_add, vec![4]);
747        assert_eq!(state.links_remove, vec![2, 3]);
748        assert!(
749            state.links.is_empty(),
750            "an empty `links` is a no-op client-side, so clearing must use links_remove"
751        );
752    }
753
754    #[test]
755    fn a_move_carries_only_the_session_and_the_channel() {
756        let messages = emit(
757            &[PlanOp::MoveUser {
758                session: SessionId(5),
759                channel: ChannelId(9),
760            }],
761            SessionId(1),
762        );
763        let ControlMessage::UserState(state) = &messages[0] else {
764            panic!("expected a UserState");
765        };
766        assert_eq!(state.session, Some(5));
767        assert_eq!(state.channel_id, Some(9));
768        assert_eq!(state.name, None, "a move must not restate the name");
769    }
770
771    #[test]
772    fn a_patch_with_no_flag_change_leaves_the_flags_unset() {
773        let patch = UserPatch {
774            session: SessionId(5),
775            name: Some("renamed".to_owned()),
776            flags: None,
777        };
778        let messages = emit(&[PlanOp::UpdateUser(patch)], SessionId(1));
779        let ControlMessage::UserState(state) = &messages[0] else {
780            panic!("expected a UserState");
781        };
782        assert_eq!(state.name.as_deref(), Some("renamed"));
783        assert_eq!(
784            state.mute, None,
785            "sending a default flag would clear a flag nobody touched"
786        );
787    }
788
789    #[test]
790    fn removals_use_the_dedicated_messages() {
791        let messages = emit(
792            &[
793                PlanOp::RemoveUser(SessionId(4)),
794                PlanOp::RemoveChannel(ChannelId(9)),
795            ],
796            SessionId(1),
797        );
798        match messages.as_slice() {
799            [
800                ControlMessage::UserRemove(removed),
801                ControlMessage::ChannelRemove(gone),
802            ] => {
803                assert_eq!(removed.session, 4);
804                assert_eq!(gone.channel_id, 9);
805            }
806            other => panic!("unexpected emission: {other:?}"),
807        }
808    }
809}