Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
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}