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 / sapart.rs
18 kB 425 lines
1use crate::command::*; 2#[cfg(test)] 3use crate::UserMode; 4 5/// `SAPART <nick> <channel>{,<channel>} [:reason]` — leveva-native operator command: forcibly 6/// part a **local** user from one or more channels. The leave companion to 7/// [`SAJOIN`](crate::command::sajoin::sajoin), completing the SA* oper-override family 8/// ([`SAMODE`](crate::command::mode::samode) / [`SANICK`](crate::command::sanick::sanick)). 9/// 10/// - Not an IRC operator → `481 ERR_NOPRIVILEGES` (mirrors `KILL`). 11/// - Missing target or channel → `461`. 12/// - Target nick not registered → `401`. 13/// - The target is not on this server (a remote/mirrored user) → a server `NOTICE` to the 14/// oper; leveva does not propagate a forced part across a link (documented scope). 15/// - Success → the part runs through the **same** machinery as a client `PART` 16/// ([`force_part`](crate::command::part::force_part)): the channel's other members get 17/// `:nick!user@host PART #chan [:reason]`, the linked network gets the `:<uid> PART`, and 18/// the forced user's own connection receives its `PART` echo on its mailbox. A channel that 19/// does not exist → `403`, or one the target is not on → `442` — both **to the oper**. The 20/// oper receives no numeric on a successful part (like `KILL`). The leave is a transparent 21/// `PART` line, indistinguishable from the user's own part. 22pub(crate) fn sapart(client: &Registered, msg: &Message, ctx: &ServerContext) -> Vec<Message> { 23 if !crate::mode::is_oper(client.modes) { 24 return vec![no_privileges(ctx, &client.nick)]; 25 } 26 let target = msg 27 .params() 28 .first() 29 .map(String::as_str) 30 .filter(|s| !s.is_empty()); 31 let chans = msg 32 .params() 33 .get(1) 34 .map(String::as_str) 35 .filter(|s| !s.is_empty()); 36 let (Some(target), Some(chans)) = (target, chans) else { 37 return vec![need_more_params(ctx, &client.nick, "SAPART")]; 38 }; 39 let reason = msg.trailing().filter(|s| !s.is_empty()); 40 41 let Some(uid) = ctx.registry.uid_of(target) else { 42 return vec![no_such_nick(ctx, &client.nick, target)]; 43 }; 44 if !ctx.is_local_uid(&uid) { 45 // Remote/mirrored target (P11 slice 183): route the forced part to the target's home 46 // server, which runs its own `force_part` (whose `PART` relay carries it network-wide). 47 // No channel-name pre-check (the local path has none either); the oper gets no `403`/ 48 // `442` for a remote target — a documented divergence. Success returns no numeric. 49 crate::s2s::sapart::propagate(ctx, &uid, chans, reason); 50 // Audit notice on the issuing server (slice 186); the remote path does no per-channel 51 // validation, so it fires on the routed set (consistent with the no-403/442 divergence). 52 let target_nick = ctx 53 .registry 54 .nick_of(&uid) 55 .unwrap_or_else(|| target.to_string()); 56 crate::snotice::server_notice( 57 ctx, 58 &format!( 59 "{} used SAPART to make {} part {}", 60 client.nick, target_nick, chans 61 ), 62 ); 63 return Vec::new(); 64 } 65 let Some(rec) = ctx.registry.record_of(&uid) else { 66 return vec![no_such_nick(ctx, &client.nick, target)]; // lost a race with a disconnect 67 }; 68 let view = crate::command::join::registered_view(&rec); 69 70 // Per channel: force the target out; any `403`/`442` is addressed to the oper (the actor), 71 // the `PART` echo lands on the target's own mailbox. 72 let mut replies = Vec::new(); 73 let mut parted: Vec<&str> = Vec::new(); 74 for chan in chans.split(',').filter(|c| !c.is_empty()) { 75 if let Some(err) = crate::command::part::force_part(ctx, &view, &client.nick, chan, reason) 76 { 77 replies.push(err); 78 } else { 79 parted.push(chan); 80 } 81 } 82 // Audit notice to local `+s` watchers (slice 186) — only for the channels actually parted. 83 if !parted.is_empty() { 84 crate::snotice::server_notice( 85 ctx, 86 &format!( 87 "{} used SAPART to make {} part {}", 88 client.nick, 89 rec.nick, 90 parted.join(",") 91 ), 92 ); 93 } 94 replies 95} 96 97#[cfg(test)] 98mod tests { 99 use super::*; 100 use crate::command::testutil::*; 101 102 /// An opered `alice` connection (the testutil fixture, plus `+o`). 103 fn oper() -> Registered { 104 let mut c = client(); 105 c.modes |= UserMode::Oper.bit(); 106 c 107 } 108 109 fn drive(ctx: &ServerContext, c: &Registered, line: &str) -> Vec<Message> { 110 dispatch(&mut c.clone(), &Message::parse(line).unwrap(), ctx) 111 } 112 113 /// Claim `nick` and give its registry record the `+s` server-notice bit. 114 fn watch_plus_s( 115 ctx: &ServerContext, 116 nick: &str, 117 ) -> tokio::sync::mpsc::UnboundedReceiver<crate::registry::Envelope> { 118 let rx = claim_observed(ctx, nick); 119 let uid = uid_for(nick); 120 let m = ctx.registry.record_of(&uid).unwrap().modes | UserMode::ServerNotice.bit(); 121 ctx.registry.set_modes(&uid, m); 122 ctx.registry.set_snomask(&uid, crate::snomask::SnoMask::ALL); 123 rx 124 } 125 126 /// A successful SAPART posts the audit server notice to local `+s` watchers (slice 186); a 127 /// `-s` watcher receives nothing (the inverse). 128 #[test] 129 fn sapart_posts_a_server_notice_to_plus_s_watchers() { 130 let ctx = ctx(); 131 let mut bob = claim_observed(&ctx, "bob"); 132 let mut watcher = watch_plus_s(&ctx, "watcher"); 133 let mut quiet = claim_observed(&ctx, "quiet"); // stays -s 134 ctx.channels.join("#rust", &uid_for("bob"), 0); 135 drive(&ctx, &oper(), "SAPART bob #rust"); 136 let _ = delivered(&mut bob); // bob's own PART echo 137 let n = delivered(&mut watcher); 138 assert_eq!(n.command(), "NOTICE"); 139 assert_eq!(n.params(), &["watcher"]); 140 assert_eq!( 141 n.trailing(), 142 Some("*** Notice -- alice used SAPART to make bob part #rust") 143 ); 144 assert!(quiet.try_recv().is_err()); 145 } 146 147 /// Inverse: a SAPART that parts nothing (channel does not exist → 403) posts no notice. 148 #[test] 149 fn sapart_posts_no_notice_when_it_does_not_act() { 150 let ctx = ctx(); 151 let _bob = claim_observed(&ctx, "bob"); 152 let mut watcher = watch_plus_s(&ctx, "watcher"); 153 assert_eq!(codes(&drive(&ctx, &oper(), "SAPART bob #nope")), &["403"]); 154 assert!( 155 watcher.try_recv().is_err(), 156 "a no-op SAPART posts no notice" 157 ); 158 assert_eq!(codes(&drive(&ctx, &client(), "SAPART bob #rust")), &["481"]); 159 assert!(watcher.try_recv().is_err(), "gated SAPART posts no notice"); 160 } 161 162 /// The gate (and its inverse): a non-operator's SAPART is `481` and parts nobody. 163 #[test] 164 fn sapart_requires_oper() { 165 let ctx = ctx(); 166 let _bob = claim_observed(&ctx, "bob"); 167 ctx.channels.join("#rust", &uid_for("bob"), 0); 168 let r = drive(&ctx, &client(), "SAPART bob #rust"); 169 assert_eq!(codes(&r), &["481"]); 170 assert!( 171 ctx.channels.is_member("#rust", &uid_for("bob")), 172 "a gated SAPART parts nobody" 173 ); 174 } 175 176 /// Success: an oper forces a user out of a channel; the target's mailbox gets its `PART` 177 /// echo, an existing member sees the `PART`, membership is gone, the oper gets no numeric. 178 #[test] 179 fn sapart_forces_a_local_user_out_of_a_channel() { 180 let ctx = ctx(); 181 let mut bob = claim_observed(&ctx, "bob"); 182 let mut carol = claim_observed(&ctx, "carol"); 183 ctx.channels.join("#rust", &uid_for("bob"), 0); 184 ctx.channels.join("#rust", &uid_for("carol"), 0); 185 let r = drive(&ctx, &oper(), "SAPART bob #rust"); 186 assert!(r.is_empty(), "the oper gets no numeric on success"); 187 assert!( 188 !ctx.channels.is_member("#rust", &uid_for("bob")), 189 "bob is no longer a member" 190 ); 191 // bob's own mailbox: his PART echo (his mask). 192 let part = delivered(&mut bob); 193 assert_eq!(part.command(), "PART"); 194 assert_eq!(part.prefix(), Some("bob!u@h")); 195 assert_eq!(part.params(), &["#rust"]); 196 // carol, the remaining member, saw bob's PART. 197 let m = delivered(&mut carol); 198 assert_eq!(m.command(), "PART"); 199 assert_eq!(m.prefix(), Some("bob!u@h")); 200 assert_eq!(m.params(), &["#rust"]); 201 } 202 203 /// The reason rides through to the `PART` trailing. 204 #[test] 205 fn sapart_carries_the_reason() { 206 let ctx = ctx(); 207 let mut bob = claim_observed(&ctx, "bob"); 208 ctx.channels.join("#rust", &uid_for("bob"), 0); 209 let r = drive(&ctx, &oper(), "SAPART bob #rust :be gone"); 210 assert!(r.is_empty()); 211 let part = delivered(&mut bob); 212 assert_eq!(part.command(), "PART"); 213 assert_eq!(part.trailing(), Some("be gone")); 214 } 215 216 /// Operand / lookup / membership errors land on the **oper**, not the target, and leave 217 /// the target's other memberships intact. 218 #[test] 219 fn sapart_errors_land_on_the_oper() { 220 let ctx = ctx(); 221 let mut bob = claim_observed(&ctx, "bob"); 222 ctx.channels.join("#rust", &uid_for("bob"), 0); 223 assert_eq!(codes(&drive(&ctx, &oper(), "SAPART")), &["461"]); 224 assert_eq!(codes(&drive(&ctx, &oper(), "SAPART bob")), &["461"]); 225 assert_eq!(codes(&drive(&ctx, &oper(), "SAPART ghost #rust")), &["401"]); 226 // A channel that does not exist → 403 to the oper. 227 assert_eq!(codes(&drive(&ctx, &oper(), "SAPART bob #nope")), &["403"]); 228 // bob is not on #other → 442 to the oper. 229 ctx.channels.join("#other", &uid_for("carol"), 0); 230 assert_eq!(codes(&drive(&ctx, &oper(), "SAPART bob #other")), &["442"]); 231 // Inverse: every failed SAPART left bob on #rust, and delivered him nothing. 232 assert!(ctx.channels.is_member("#rust", &uid_for("bob"))); 233 assert!(bob.try_recv().is_err(), "no PART for a failed SAPART"); 234 } 235 236 /// A remote/mirrored target (P11 slice 183): the forced part is routed to the home server as 237 /// `:<sid> ENCAP * SAPART <uid> <chans> [:reason]` — no `NOTICE`, no numeric. The remote 238 /// target's mirrored membership is untouched locally (the home server's `PART` relay reconciles 239 /// it). With no peer linked the routed broadcast is a silent no-op. 240 #[test] 241 fn sapart_remote_target_routes_to_the_home_server() { 242 let ctx = ctx(); 243 // A remote user is one whose UID carries a foreign SID prefix (≠ the local 0ABC), so 244 // `is_local_uid` is false; it is `try_claim`ed into the same registry with no drained 245 // mailbox, exactly as an S2S burst mirrors one. 246 let remote = crate::ident::Uid::try_from("0XYZAAAAA").unwrap(); 247 ctx.registry 248 .try_claim(&remote, "zane", "u", "h", "real", mb()) 249 .expect("free remote nick"); 250 ctx.channels.join("#rust", &remote, 0); 251 let r = drive(&ctx, &oper(), "SAPART zane #rust :bye"); 252 assert!(r.is_empty(), "remote SAPART returns no numeric/NOTICE"); 253 assert!( 254 ctx.channels.is_member("#rust", &remote), 255 "the home server (not us) reconciles the remote target's membership" 256 ); 257 } 258 259 /// Remote target with a peer linked (P11 slice 183): the forced part is routed as a 260 /// server-sourced `ENCAP * SAPART <uid> <chans> :reason` to the home server. 261 #[test] 262 fn sapart_of_a_remote_user_routes_an_encap() { 263 let ctx = ctx(); 264 let mut peer = link_observed_peer(&ctx); 265 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); 266 let rover = crate::ident::Uid::try_from("0XYZAAAAA").unwrap(); 267 ctx.registry 268 .try_claim(&rover, "rover", "ru", "rh", "Rover", tx) 269 .expect("fresh nick"); 270 271 let r = drive(&ctx, &oper(), "SAPART rover #a,#b :bye"); 272 assert!( 273 r.is_empty(), 274 "no NOTICE, no numeric — routed across the link" 275 ); 276 let m = delivered(&mut peer); 277 assert_eq!(m.command(), "ENCAP"); 278 assert_eq!(m.prefix(), Some("0ABC")); 279 assert_eq!(m.params(), &["*", "SAPART", "0XYZAAAAA", "#a,#b"]); 280 assert_eq!(m.trailing(), Some("bye")); 281 } 282 283 /// Forcing the last member out deletes the channel (the force path drives the same 284 /// delete-on-empty as `PART`). 285 #[test] 286 fn sapart_of_the_last_member_deletes_the_channel() { 287 let ctx = ctx(); 288 let _bob = claim_observed(&ctx, "bob"); 289 ctx.channels.join("#solo", &uid_for("bob"), 0); 290 assert_eq!(ctx.channels.len(), 1); 291 let r = drive(&ctx, &oper(), "SAPART bob #solo"); 292 assert!(r.is_empty()); 293 assert_eq!(ctx.channels.len(), 0, "empty channel removed"); 294 } 295} 296 297#[cfg(test)] 298mod proptests { 299 use crate::command::testutil::*; 300 use crate::command::*; 301 use crate::registry::Envelope; 302 use crate::UserMode; 303 use proptest::prelude::*; 304 305 const CHAN_POOL: usize = 6; 306 307 /// An opered `alice` (the actor for SAPART). 308 fn oper() -> Registered { 309 let mut c = client(); 310 c.modes |= UserMode::Oper.bit(); 311 c 312 } 313 314 /// Build a world where `victim` holds exactly the `joined` pool channels, plus a co-member 315 /// `mate` on each (so a part has someone to notify and the channel is not deleted by the 316 /// victim's own leave). Returns the ctx, the victim's `Registered` (mask-identical to its 317 /// registry record), and the victim's mailbox receiver. 318 fn world( 319 joined: &std::collections::BTreeSet<usize>, 320 ) -> ( 321 std::sync::Arc<ServerContext>, 322 Registered, 323 tokio::sync::mpsc::UnboundedReceiver<Envelope>, 324 ) { 325 let ctx = ctx(); 326 let vrx = claim_observed(&ctx, "victim"); 327 let _mate = claim_observed(&ctx, "mate"); 328 for &i in joined { 329 let chan = format!("#c{i}"); 330 ctx.channels.join(&chan, &uid_for("victim"), 0); 331 ctx.channels.join(&chan, &uid_for("mate"), 0); 332 } 333 let rec = ctx.registry.record_of(&uid_for("victim")).unwrap(); 334 let view = crate::command::join::registered_view(&rec); 335 (ctx, view, vrx) 336 } 337 338 /// Collect the `PART`-command lines (prefix + channel) from a slice of returned replies. 339 fn part_lines(ms: &[Message]) -> Vec<(Option<String>, Vec<String>)> { 340 ms.iter() 341 .filter(|m| m.command() == "PART") 342 .map(|m| { 343 ( 344 m.prefix().map(str::to_string), 345 m.params().iter().map(|p| p.to_string()).collect(), 346 ) 347 }) 348 .collect() 349 } 350 351 /// Drain a mailbox into the same `PART`-line shape as [`part_lines`]. 352 fn mailbox_part_lines( 353 rx: &mut tokio::sync::mpsc::UnboundedReceiver<Envelope>, 354 ) -> Vec<(Option<String>, Vec<String>)> { 355 let mut out = Vec::new(); 356 while let Ok(Envelope::Wire(b)) = rx.try_recv() { 357 let m = Message::parse(&String::from_utf8_lossy(&b)).unwrap(); 358 if m.command() == "PART" { 359 out.push(( 360 m.prefix().map(str::to_string), 361 m.params().iter().map(|p| p.to_string()).collect(), 362 )); 363 } 364 } 365 out 366 } 367 368 proptest! { 369 #![proptest_config(ProptestConfig::with_cases(256))] 370 371 /// SAPART by an oper leaves the network in the identical state as the target PARTing 372 /// those same channels itself — same residual membership across every pool channel, 373 /// same delete-on-empty, same `PART` lines on the victim's connection — differing only 374 /// in who issues it and where the replies are routed. And a SAPART from a channel the 375 /// target is not on changes nothing. Never panics. 376 #[test] 377 fn sapart_equals_the_targets_own_part( 378 joined in prop::collection::vec(0usize..CHAN_POOL, 0..8), 379 parted in prop::collection::vec(0usize..CHAN_POOL, 0..8), 380 ) { 381 let joined: std::collections::BTreeSet<usize> = joined.into_iter().collect(); 382 let line = parted 383 .iter() 384 .map(|i| format!("#c{i}")) 385 .collect::<Vec<_>>() 386 .join(","); 387 if line.is_empty() { 388 return Ok(()); // a bare PART/SAPART is a 461 in both worlds — nothing to compare 389 } 390 391 // World A: the victim PARTs the subset itself (echoes return to it). 392 let (ctx_a, victim, mut vrx_a) = world(&joined); 393 let a_replies = dispatch( 394 &mut victim.clone(), 395 &Message::parse(&format!("PART {line} :bye")).unwrap(), 396 &ctx_a, 397 ); 398 399 // World B: an oper SAPARTs the victim from the same subset (echoes hit its mailbox). 400 let (ctx_b, _victim_b, mut vrx_b) = world(&joined); 401 let _b_replies = dispatch( 402 &mut oper(), 403 &Message::parse(&format!("SAPART victim {line} :bye")).unwrap(), 404 &ctx_b, 405 ); 406 407 // Residual membership identical across the whole pool. 408 for i in 0..CHAN_POOL { 409 let chan = format!("#c{i}"); 410 prop_assert_eq!( 411 ctx_a.channels.is_member(&chan, &uid_for("victim")), 412 ctx_b.channels.is_member(&chan, &uid_for("victim")), 413 "channel {} membership diverged", i 414 ); 415 } 416 // Delete-on-empty identical. 417 prop_assert_eq!(ctx_a.channels.len(), ctx_b.channels.len()); 418 // The PART lines the victim sees are identical (A: its own replies; B: its mailbox). 419 // Drain world A's mailbox too (it must hold no PART — the victim's echo came back as 420 // a reply, not a delivery). 421 prop_assert!(mailbox_part_lines(&mut vrx_a).is_empty()); 422 prop_assert_eq!(part_lines(&a_replies), mailbox_part_lines(&mut vrx_b)); 423 } 424 } 425}