Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
1//! `CHGHOST` — the operator command that changes the issuer's own `user@host`, the
2//! origination point of the IRCv3 [`chghost`](../../docs/rfcs/ircv3/chghost.md) capability.
3//!
4//! `CHGHOST <newuser> <newhost>` (operator-only, like [`KILL`](super::kill)) rewrites the
5//! issuing connection's username and visible host. Because the issuer owns its
6//! [`Registered`] state, the mutation is in-place and authoritative; the change is then:
7//!
8//! 1. **mirrored** into the shared registry record ([`Registry::set_host`]) so WHO / WHOIS /
9//! `userhost-in-names` show the new mask,
10//! 2. **notified** to every local channel co-member that negotiated `chghost`
11//! ([`crate::s2s::chghost::notify_co_members`]) as `:nick!old_user@old_host CHGHOST
12//! <newuser> <newhost>` (the prefix carries the **old** mask, per the spec),
13//! 3. **propagated** network-wide as `:<uid> ENCAP * CHGHOST <newuser> <newhost>`
14//! ([`crate::s2s::chghost::local_chghost`]).
15//!
16//! The spec SHOULD also notify the changed client itself if it supports `chghost`; the
17//! issuer is excluded from `co_members`, so that self-echo is the handler's return value
18//! (gated on the issuer's own cap).
19//!
20//! # Divergences (documented)
21//!
22//! - **Self-only.** `CHGHOST` changes the *issuing operator's* identity; targeting another
23//! user needs a control-envelope to push the new identity onto that user's serve loop
24//! (the only [`Envelope`](crate::registry::Envelope) variants today are `Wire`/`Eject`) —
25//! a future extension.
26//! - **No non-cap fallback.** The spec's SHOULD QUIT/JOIN/MODE simulation for non-cap
27//! co-members is not sent; they keep the stale host until their next sweep (the track's
28//! single-server-local stance).
29//! - **Invalid input → `NOTICE`.** A `newuser`/`newhost` that fails the typed
30//! [`UserName`]/[`HostName`] validators is reported by a server `NOTICE` (chghost has no
31//! standard-replies code) and changes nothing — no mutation, no propagation.
32
33use crate::command::*;
34use crate::ident::{HostName, UserName};
35#[cfg(test)]
36use crate::UserMode;
37
38/// `CHGHOST <newuser> <newhost>` — change the issuing operator's own `user@host`.
39pub(crate) fn chghost(client: &mut Registered, msg: &Message, ctx: &ServerContext) -> Vec<Message> {
40 // Operator-gated (the oracle `m_nopriv`), exactly as KILL — no per-`ACL_*` privilege.
41 if !crate::mode::is_oper(client.modes) {
42 return vec![no_privileges(ctx, &client.nick)];
43 }
44 let params = msg.params();
45 let (newuser, newhost) = match (params.first(), params.get(1)) {
46 (Some(u), Some(h)) => (u.as_str(), h.as_str()),
47 _ => return vec![need_more_params(ctx, &client.nick, "CHGHOST")],
48 };
49 // Validate against the typed identifier rules; an invalid value is reported by NOTICE
50 // (chghost has no standard-replies code) and changes nothing.
51 if UserName::try_from(newuser).is_err() {
52 return vec![notice(
53 ctx,
54 &client.nick,
55 &format!("CHGHOST: invalid username '{newuser}'"),
56 )];
57 }
58 if HostName::try_from(newhost).is_err() {
59 return vec![notice(
60 ctx,
61 &client.nick,
62 &format!("CHGHOST: invalid hostname '{newhost}'"),
63 )];
64 }
65 // A value beginning with ':' validates (RFC-1459 idents and IPv6-literal hosts both admit
66 // ':'), but cannot be carried as an IRC middle parameter — it would reparse as the trailing
67 // and corrupt the CHGHOST line. Reject it rather than emit an unrepresentable mask.
68 if newuser.starts_with(':') || newhost.starts_with(':') {
69 return vec![notice(
70 ctx,
71 &client.nick,
72 "CHGHOST: username and hostname may not begin with ':'",
73 )];
74 }
75 // The old identity prefixes every CHGHOST line — capture it before mutating.
76 let nick = client.nick.clone();
77 let old_user = client.user.clone();
78 let old_host = client.host.clone();
79 client.user = newuser.to_string();
80 client.host = newhost.to_string();
81 ctx.registry.set_host(&client.uid, newuser, newhost);
82 // Local co-members (cap → CHGHOST, non-cap → QUIT/JOIN/MODE fallback; self excluded) +
83 // the network.
84 crate::s2s::chghost::notify_co_members(
85 ctx,
86 &client.uid,
87 &nick,
88 &old_user,
89 &old_host,
90 newuser,
91 newhost,
92 );
93 crate::s2s::chghost::local_chghost(ctx, &client.uid, newuser, newhost);
94 // The spec SHOULD also notify the changed client itself, iff it negotiated chghost.
95 let mut out: Vec<Message> = Vec::new();
96 if client.caps.chghost {
97 let old_mask = format!("{nick}!{old_user}@{old_host}");
98 out.push(
99 Message::builder("CHGHOST")
100 .prefix(&old_mask)
101 .param(newuser)
102 .param(newhost)
103 .build(),
104 );
105 }
106 // Auto-reop: the new `user@host` may move the issuer **into** a channel's `+R` reop set,
107 // so fire the reop invariant on every channel she is a member of now that the registry
108 // mirror carries the new mask. `enforce_reop` ops a matching non-op member, fans the
109 // server-sourced `MODE +o` out to the other local members + the network, and returns the
110 // line the issuer should see — appended after her `CHGHOST` self-echo. (A non-match, an
111 // existing op, or a move *out* of a matching mask all yield no MODE — reop is additive.)
112 for chan in ctx.channels.member_channels(&client.uid) {
113 out.extend(enforce_reop(
114 ctx,
115 &chan,
116 std::slice::from_ref(&client.uid),
117 Some(&client.uid),
118 ));
119 }
120 out
121}
122
123/// A server `NOTICE <nick> :<text>` (the chghost invalid-input report).
124fn notice(ctx: &ServerContext, nick: &str, text: &str) -> Message {
125 Message::builder("NOTICE")
126 .prefix(&ctx.name)
127 .param(nick)
128 .trailing(text)
129 .build()
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use crate::cap::ClientCaps;
136 use crate::command::testutil::*;
137 use crate::ident::{ServerName, Sid};
138 use crate::registry::Envelope;
139 use crate::s2s::network::RemoteServer;
140 use crate::s2s::PeerLink;
141 use tokio::sync::mpsc::UnboundedReceiver;
142
143 /// An operator `alice` connection (the issuer), claimed + observable in the registry.
144 fn oper_alice(ctx: &ServerContext) -> (Registered, UnboundedReceiver<Envelope>) {
145 let rx = claim_observed(ctx, "alice");
146 let mut c = client();
147 c.modes |= UserMode::Oper.bit();
148 (c, rx)
149 }
150
151 /// Claim `nick`, give it `chghost` iff `cap`, and join it to `chan`. Returns the mailbox.
152 fn co_member(
153 ctx: &ServerContext,
154 nick: &str,
155 chan: &str,
156 cap: bool,
157 ) -> UnboundedReceiver<Envelope> {
158 let rx = claim_observed(ctx, nick);
159 if cap {
160 ctx.registry.set_caps(
161 &uid_for(nick),
162 ClientCaps {
163 chghost: true,
164 ..Default::default()
165 },
166 );
167 }
168 ctx.channels.join(chan, &uid_for(nick), 0);
169 rx
170 }
171
172 fn link_peer(ctx: &ServerContext) -> (PeerLink, UnboundedReceiver<Envelope>) {
173 let (link, rx) = PeerLink::new(
174 Sid::try_from("0XYZ").unwrap(),
175 ServerName::try_from("peer.test").unwrap(),
176 );
177 ctx.net.insert_server(RemoteServer {
178 sid: link.sid.clone(),
179 name: link.name.clone(),
180 description: "Peer".into(),
181 hopcount: 1,
182 uplink: link.sid.clone(),
183 parent: ctx.uids.sid().clone(),
184 });
185 ctx.peers
186 .link(link.sid.clone(), link.mailbox_tx.clone(), link.caps);
187 (link, rx)
188 }
189
190 fn drive(client: &mut Registered, ctx: &ServerContext, line: &str) -> Vec<Message> {
191 chghost(client, &Message::parse(line).unwrap(), ctx)
192 }
193
194 // ---- auto-reop on host change (slice 166) ----
195
196 /// Seed a `+R` reop mask onto `chan` via the trusted server path (no actor/op gate).
197 fn set_reop(ctx: &ServerContext, chan: &str, mask: &str) {
198 ctx.channels.apply_modes_as_server(
199 chan,
200 &[crate::channel::ModeChange::ListMask {
201 add: true,
202 mode: crate::command::ChanMode::ReopList,
203 mask: mask.into(),
204 }],
205 );
206 }
207
208 /// A `CHGHOST` whose **new** mask matches a channel's `+R` reop entry auto-ops the issuer
209 /// on the spot: a server-sourced `MODE #c +o alice` is returned to her and delivered to a
210 /// cap co-member after the `CHGHOST` line.
211 #[test]
212 fn chghost_into_a_matching_reop_mask_auto_ops_the_issuer() {
213 let ctx = ctx();
214 let (mut alice, _rx) = oper_alice(&ctx);
215 // bob creates #c (first in → op, cap); alice joins second → plain member.
216 let mut bob = co_member(&ctx, "bob", "#c", true);
217 ctx.channels.join("#c", &uid_for("alice"), 0);
218 // A reop entry matching alice's NEW host only (not 127.0.0.1).
219 set_reop(&ctx, "#c", "*!*@user.cloak");
220 assert!(!ctx.channels.is_op("#c", &uid_for("alice")), "plain before");
221
222 let r = drive(&mut alice, &ctx, "CHGHOST web user.cloak");
223
224 assert!(
225 ctx.channels.is_op("#c", &uid_for("alice")),
226 "alice reopped on the matching host change"
227 );
228 let mode = r
229 .iter()
230 .find(|m| m.command() == "MODE")
231 .expect("the reop MODE is returned to the issuer");
232 assert_eq!(mode.prefix(), Some("leveva.test"), "server-sourced");
233 assert_eq!(mode.params(), &["#c", "+o", "alice"]);
234 // bob (cap co-member) sees the CHGHOST line, then the reop +o.
235 assert_eq!(delivered(&mut bob).command(), "CHGHOST");
236 let bm = delivered(&mut bob);
237 assert_eq!(bm.command(), "MODE");
238 assert_eq!(bm.params(), &["#c", "+o", "alice"]);
239 }
240
241 /// Inverse — a `CHGHOST` to a host that matches no `+R` entry leaves the issuer a plain
242 /// member and returns no `MODE`.
243 #[test]
244 fn chghost_into_a_non_matching_host_does_not_reop() {
245 let ctx = ctx();
246 let (mut alice, _rx) = oper_alice(&ctx);
247 let _bob = co_member(&ctx, "bob", "#c", true);
248 ctx.channels.join("#c", &uid_for("alice"), 0);
249 set_reop(&ctx, "#c", "*!*@trusted.example");
250
251 let r = drive(&mut alice, &ctx, "CHGHOST web user.cloak");
252
253 assert!(
254 !ctx.channels.is_op("#c", &uid_for("alice")),
255 "no match → no op"
256 );
257 assert!(
258 !r.iter().any(|m| m.command() == "MODE"),
259 "no reop MODE emitted"
260 );
261 }
262
263 /// Inverse — an issuer who is **already** an op and `CHGHOST`s into a matching mask is not
264 /// re-opped (no duplicate `MODE`); `apply_reop` skips existing operators.
265 #[test]
266 fn chghost_does_not_duplicate_op_for_an_existing_op() {
267 let ctx = ctx();
268 let (mut alice, _rx) = oper_alice(&ctx);
269 ctx.channels.join("#c", &uid_for("alice"), 0); // alice first in → already op
270 set_reop(&ctx, "#c", "*!*@user.cloak");
271 assert!(ctx.channels.is_op("#c", &uid_for("alice")), "op before");
272
273 let r = drive(&mut alice, &ctx, "CHGHOST web user.cloak");
274
275 assert!(ctx.channels.is_op("#c", &uid_for("alice")), "still op");
276 assert!(
277 !r.iter().any(|m| m.command() == "MODE"),
278 "already op → no spurious reop MODE"
279 );
280 }
281
282 /// Inverse — reop is additive: `CHGHOST`ing *out* of a matching host never de-ops a
283 /// member who was reopped under the old mask.
284 #[test]
285 fn chghost_out_of_a_matching_host_does_not_deop() {
286 let ctx = ctx();
287 let (mut alice, _rx) = oper_alice(&ctx);
288 let _bob = co_member(&ctx, "bob", "#c", true);
289 ctx.channels.join("#c", &uid_for("alice"), 0); // plain member
290 // Reop the OLD registry host (`alice!u@h`), then change away from it.
291 set_reop(&ctx, "#c", "*!*@h");
292 crate::command::enforce_reop(&ctx, "#c", &[uid_for("alice")], Some(&uid_for("alice")));
293 assert!(
294 ctx.channels.is_op("#c", &uid_for("alice")),
295 "opped on old host"
296 );
297
298 let r = drive(&mut alice, &ctx, "CHGHOST web user.cloak");
299
300 assert!(
301 ctx.channels.is_op("#c", &uid_for("alice")),
302 "still op — reop never de-ops"
303 );
304 assert!(
305 !r.iter().any(|m| m.command() == "MODE"),
306 "no change → no MODE"
307 );
308 }
309
310 #[test]
311 fn non_oper_gets_481_and_changes_nothing() {
312 let ctx = ctx();
313 let _rx = claim_observed(&ctx, "alice");
314 let mut alice = client(); // not an operator
315 let r = drive(&mut alice, &ctx, "CHGHOST web user.cloak");
316 assert_eq!(codes(&r), &["481"]);
317 // Inverse: identity untouched on both the connection and the registry mirror.
318 assert_eq!(alice.user, "alice");
319 assert_eq!(alice.host, "127.0.0.1");
320 let rec = ctx.registry.record_of(&uid_for("alice")).unwrap();
321 assert_eq!((rec.user.as_str(), rec.host.as_str()), ("u", "h"));
322 }
323
324 #[test]
325 fn too_few_params_gives_461() {
326 let ctx = ctx();
327 let (mut alice, _rx) = oper_alice(&ctx);
328 let r = drive(&mut alice, &ctx, "CHGHOST web");
329 assert_eq!(codes(&r), &["461"]);
330 assert_eq!(alice.user, "alice", "no partial change");
331 }
332
333 #[test]
334 fn oper_chghost_updates_self_and_mirrors_registry() {
335 let ctx = ctx();
336 let (mut alice, _rx) = oper_alice(&ctx);
337 let r = drive(&mut alice, &ctx, "CHGHOST web user.cloak");
338 assert!(r.is_empty(), "no cap → no self-echo");
339 assert_eq!(alice.user, "web");
340 assert_eq!(alice.host, "user.cloak");
341 let rec = ctx.registry.record_of(&uid_for("alice")).unwrap();
342 assert_eq!(
343 (rec.user.as_str(), rec.host.as_str()),
344 ("web", "user.cloak")
345 );
346 // Inverse: the nick is unchanged and still resolves.
347 assert_eq!(rec.nick, "alice");
348 assert_eq!(ctx.registry.uid_of("alice"), Some(uid_for("alice")));
349 }
350
351 #[test]
352 fn a_capable_co_member_is_notified_with_the_old_mask() {
353 let ctx = ctx();
354 let (mut alice, _rx) = oper_alice(&ctx);
355 ctx.channels.join("#c", &uid_for("alice"), 0);
356 let mut bob = co_member(&ctx, "bob", "#c", true);
357 drive(&mut alice, &ctx, "CHGHOST web user.cloak");
358 let m = delivered(&mut bob);
359 assert_eq!(m.command(), "CHGHOST");
360 assert_eq!(m.prefix(), Some("alice!alice@127.0.0.1"), "old mask prefix");
361 assert_eq!(m.params(), &["web", "user.cloak"]);
362 }
363
364 #[test]
365 fn a_non_cap_co_member_gets_the_reconnect_fallback() {
366 let ctx = ctx();
367 let (mut alice, _rx) = oper_alice(&ctx);
368 ctx.channels.join("#c", &uid_for("alice"), 0); // alice is the op (first in)
369 let mut mel = co_member(&ctx, "bob", "#c", false); // shares #c, no cap
370 drive(&mut alice, &ctx, "CHGHOST web user.cloak");
371 // The non-cap client sees a simulated reconnect: QUIT (old mask) → JOIN (new mask) →
372 // a server MODE restoring alice's chanop status.
373 let q = delivered(&mut mel);
374 assert_eq!(q.command(), "QUIT");
375 assert_eq!(q.prefix(), Some("alice!alice@127.0.0.1"));
376 assert_eq!(q.trailing(), Some("Changing host"));
377 let j = delivered(&mut mel);
378 assert_eq!(j.command(), "JOIN");
379 assert_eq!(
380 j.prefix(),
381 Some("alice!web@user.cloak"),
382 "JOIN carries the new mask"
383 );
384 assert_eq!(j.params(), &["#c"]);
385 let mode = delivered(&mut mel);
386 assert_eq!(mode.command(), "MODE");
387 assert_eq!(mode.prefix(), Some("leveva.test"));
388 assert_eq!(mode.params(), &["#c", "+o", "alice"]);
389 // Inverse: no CHGHOST line ever reaches the non-cap client.
390 assert!(
391 mel.try_recv().is_err(),
392 "fallback is exactly QUIT/JOIN/MODE"
393 );
394 }
395
396 #[test]
397 fn a_non_cap_co_member_without_status_gets_no_mode_restore() {
398 let ctx = ctx();
399 let (mut alice, _rx) = oper_alice(&ctx);
400 // bob creates #c (op); alice joins second → no status to restore.
401 let _bobrx = claim_observed(&ctx, "bob");
402 ctx.channels.join("#c", &uid_for("bob"), 0);
403 ctx.channels.join("#c", &uid_for("alice"), 0);
404 let mut mel = co_member(&ctx, "carol", "#c", false);
405 drive(&mut alice, &ctx, "CHGHOST web user.cloak");
406 assert_eq!(delivered(&mut mel).command(), "QUIT");
407 assert_eq!(delivered(&mut mel).command(), "JOIN");
408 // alice holds no @/+ in #c → no MODE line follows the JOIN.
409 assert!(mel.try_recv().is_err(), "no status → no MODE restore");
410 }
411
412 #[test]
413 fn a_capable_non_co_member_is_not_notified() {
414 let ctx = ctx();
415 let (mut alice, _rx) = oper_alice(&ctx);
416 ctx.channels.join("#mine", &uid_for("alice"), 0);
417 let mut bob = co_member(&ctx, "bob", "#elsewhere", true); // cap, but no shared channel
418 drive(&mut alice, &ctx, "CHGHOST web user.cloak");
419 assert!(bob.try_recv().is_err(), "chghost is channel-scoped");
420 }
421
422 #[test]
423 fn self_echo_only_when_the_oper_has_the_cap() {
424 let ctx = ctx();
425 let (mut alice, _rx) = oper_alice(&ctx);
426 alice.caps.chghost = true;
427 let r = drive(&mut alice, &ctx, "CHGHOST web user.cloak");
428 assert_eq!(codes(&r), &["CHGHOST"], "cap → self-echo");
429 assert_eq!(r[0].prefix(), Some("alice!alice@127.0.0.1"));
430 assert_eq!(r[0].params(), &["web", "user.cloak"]);
431 }
432
433 #[test]
434 fn invalid_hostname_gives_a_notice_and_does_not_propagate() {
435 let ctx = ctx();
436 let (mut alice, _rx) = oper_alice(&ctx);
437 let (_peer, mut peer_rx) = link_peer(&ctx);
438 let r = drive(&mut alice, &ctx, "CHGHOST web ho@st"); // '@' is an invalid host char
439 assert_eq!(codes(&r), &["NOTICE"]);
440 // Inverse: nothing changed and nothing was sent to the peer.
441 assert_eq!(alice.host, "127.0.0.1");
442 assert!(
443 peer_rx.try_recv().is_err(),
444 "invalid input does not propagate"
445 );
446 }
447
448 #[test]
449 fn a_leading_colon_value_is_rejected() {
450 // ':' passes the typed validators (RFC-1459 idents / IPv6 hosts admit it) but cannot be
451 // an IRC middle parameter — the handler must reject it rather than emit a broken mask.
452 // The wire parser never yields a leading-':' middle param, so build the message
453 // directly (the defensive guard the chghost proptest exercises).
454 let ctx = ctx();
455 let (mut alice, _rx) = oper_alice(&ctx);
456 for (u, h) in [(":weird", "host.ok"), ("web", ":badhost")] {
457 let msg = Message::builder("CHGHOST").param(u).param(h).build();
458 let r = chghost(&mut alice, &msg, &ctx);
459 assert_eq!(codes(&r), &["NOTICE"], "leading-colon {u}/{h} rejected");
460 }
461 assert_eq!(alice.user, "alice", "no change from a rejected value");
462 assert_eq!(alice.host, "127.0.0.1");
463 }
464
465 #[test]
466 fn invalid_username_gives_a_notice() {
467 let ctx = ctx();
468 let (mut alice, _rx) = oper_alice(&ctx);
469 let r = drive(&mut alice, &ctx, "CHGHOST waytoolongname user.cloak");
470 assert_eq!(codes(&r), &["NOTICE"]);
471 assert_eq!(alice.user, "alice", "no change on invalid username");
472 }
473
474 // ---- IRCv3 extended-monitor: CHGHOST to a monitor-only watcher ----
475
476 /// Claim `nick`, give it extended-monitor + chghost, and MONITOR `alice` — sharing no channel.
477 fn ext_monitor_watcher(ctx: &ServerContext, nick: &str) -> UnboundedReceiver<Envelope> {
478 let rx = claim_observed(ctx, nick);
479 ctx.registry.set_caps(
480 &uid_for(nick),
481 ClientCaps {
482 extended_monitor: true,
483 chghost: true,
484 ..Default::default()
485 },
486 );
487 ctx.monitors.add(&uid_for(nick), "alice");
488 rx
489 }
490
491 #[test]
492 fn chghost_notifies_an_extended_monitor_watcher_sharing_no_channel() {
493 let ctx = ctx();
494 let (mut alice, _rx) = oper_alice(&ctx);
495 // bob monitors alice with extended-monitor + chghost; they share NO channel.
496 let mut bob = ext_monitor_watcher(&ctx, "bob");
497 drive(&mut alice, &ctx, "CHGHOST web user.cloak");
498 let m = delivered(&mut bob);
499 assert_eq!(m.command(), "CHGHOST");
500 assert_eq!(m.prefix(), Some("alice!alice@127.0.0.1"), "old mask prefix");
501 assert_eq!(m.params(), &["web", "user.cloak"]);
502 // Inverse: exactly one line — no reconnect fallback for a monitor-only watcher.
503 assert!(
504 bob.try_recv().is_err(),
505 "monitor watcher gets only the CHGHOST line"
506 );
507 }
508
509 #[test]
510 fn a_monitor_watcher_without_extended_monitor_is_not_chghost_notified() {
511 let ctx = ctx();
512 let (mut alice, _rx) = oper_alice(&ctx);
513 // bob monitors alice with chghost but NOT extended-monitor → channel-scoped only.
514 let mut bob = claim_observed(&ctx, "bob");
515 ctx.registry.set_caps(
516 &uid_for("bob"),
517 ClientCaps {
518 chghost: true,
519 ..Default::default()
520 },
521 );
522 ctx.monitors.add(&uid_for("bob"), "alice");
523 drive(&mut alice, &ctx, "CHGHOST web user.cloak");
524 assert!(
525 bob.try_recv().is_err(),
526 "no extended-monitor → no monitor-scope CHGHOST"
527 );
528 }
529
530 #[test]
531 fn a_co_member_extended_monitor_watcher_is_not_double_notified() {
532 let ctx = ctx();
533 let (mut alice, _rx) = oper_alice(&ctx);
534 ctx.channels.join("#c", &uid_for("alice"), 0);
535 // bob both shares #c AND monitors alice with the caps → exactly one CHGHOST (dedup).
536 let mut bob = ext_monitor_watcher(&ctx, "bob");
537 ctx.channels.join("#c", &uid_for("bob"), 0);
538 drive(&mut alice, &ctx, "CHGHOST web user.cloak");
539 assert_eq!(delivered(&mut bob).command(), "CHGHOST");
540 assert!(
541 bob.try_recv().is_err(),
542 "co-member + watcher deduped to one line"
543 );
544 }
545
546 #[test]
547 fn the_encap_line_is_broadcast_to_a_peer() {
548 let ctx = ctx();
549 let (mut alice, _rx) = oper_alice(&ctx);
550 let (_peer, mut peer_rx) = link_peer(&ctx);
551 drive(&mut alice, &ctx, "CHGHOST web user.cloak");
552 match peer_rx.try_recv().expect("peer received a frame") {
553 Envelope::Wire(b) => assert_eq!(
554 String::from_utf8(b).unwrap(),
555 ":0ABCAAAAA ENCAP * CHGHOST web user.cloak\r\n"
556 ),
557 other => panic!("expected wire, got {other:?}"),
558 }
559 }
560}