Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
0

Configure Feed

Select the types of activity you want to include in your feed.

ircd.rs / leveva / src / command / chghost.rs
23 kB 560 lines
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}