diff --git a/src/cmds.rs b/src/cmds.rs index cdf3c81..6a30366 100644 --- a/src/cmds.rs +++ b/src/cmds.rs @@ -3466,10 +3466,11 @@ fn deliver_one_recipient(stg: &mut ServerState, id: usize, raw: &str, text: &str } if valid_channel(raw) { - deliver_to_channel(stg, id, raw, text, is_priv); - // A channel may hold members on other servers. Relaying after local - // delivery keeps the local view authoritative if the link is down. - relay_to_links(stg, id, raw, text, is_priv); + // A refused send must not cross the link either, or the peer would + // still show a line this server told the sender it rejected. + if deliver_to_channel(stg, id, raw, text, is_priv) { + relay_to_links(stg, id, raw, text, is_priv); + } return; } @@ -3775,7 +3776,32 @@ fn deliver_to_server_mask(stg: &mut ServerState, id: usize, raw: &str, text: &st } } -/// Channel dispatch honoring +i/+n and moderated(+m) gates under locked reply policies. +/// True when this client must not send to the channel. +/// +/// A `+b` mask blocks speech, not only joining. Kick removes membership and +/// nothing else, so without this check a banned user keeps talking from +/// outside — the ban is announced, the nick leaves the names list, and the +/// lines still arrive. `+e` overrides the ban. `+n` blocks outsiders only; +/// members still speak. `+m` blocks anyone who is not op or voice. `+i` +/// blocks outsiders. +fn channel_send_denied(stg: &ServerState, id: usize, norm: &str) -> bool { + let banned = match (stg.find_by_id(id), stg.chan(norm)) { + (Some(u), Some(c)) => c.ban_match(u).is_some() && !c.except_match(u), + (None, Some(_)) => true, + _ => return true, + }; + match stg.chan(norm) { + Some(c) => { + let member = c.is_member(id); + banned + || (c.nomsg() && !member) + || (c.moderated() && !c.is_op(id) && !c.is_voiced(id)) + || (c.invite_only() && !member) + } + None => true, + } +} + /// STATUSMSG target parse: "@#chan" or "+#chan" -> (prefix, channel). fn split_statusmsg(raw: &str) -> Option<(char, &str)> { let mut chars = raw.chars(); @@ -3811,15 +3837,7 @@ fn deliver_to_channel_status( } return; } - let gate_denied = match stg.chan(&norm) { - Some(c) => { - c.nomsg() - || (c.moderated() && !c.is_op(id) && !c.is_voiced(id)) - || (c.invite_only() && !c.is_member(id)) - } - None => true, - }; - if gate_denied { + if channel_send_denied(stg, id, &norm) { if is_priv { numeric(stg, id, "404", &[full_target, "Cannot send to channel"]); } @@ -3853,14 +3871,22 @@ fn deliver_to_channel_status( } } -fn deliver_to_channel(stg: &mut ServerState, id: usize, raw: &str, text: &str, is_priv: bool) { +/// Deliver one channel message. Returns false when the send was refused, so +/// the caller does not relay it to other servers. +fn deliver_to_channel( + stg: &mut ServerState, + id: usize, + raw: &str, + text: &str, + is_priv: bool, +) -> bool { let norm_key: String = raw.fold_rfc1459(); if stg.chan(&norm_key).is_none() { if is_priv { deliver_nosuch_channel(stg, id, raw); } - return; + return false; } let members_now: Vec = stg @@ -3868,20 +3894,11 @@ fn deliver_to_channel(stg: &mut ServerState, id: usize, raw: &str, text: &str, i .map(|c| c.members.iter().copied().collect::>()) .unwrap_or_default(); - let gate_denied: bool = match stg.chan(&norm_key) { - Some(c) => { - c.nomsg() - || (c.moderated() && !c.is_op(id) && !c.is_voiced(id)) - || (c.invite_only() && !c.is_member(id)) - } - None => true, - }; - - if gate_denied { + if channel_send_denied(stg, id, &norm_key) { if is_priv { numeric(stg, id, "404", &[raw, "Cannot send to channel"]); } // recipient token first via the shared chokepoint - return; + return false; } let prefix_now: String = stg.find_by_id(id).map(|u| u.prefix()).unwrap_or_default(); @@ -3901,6 +3918,7 @@ fn deliver_to_channel(stg: &mut ServerState, id: usize, raw: &str, text: &str, i ), ); } + true } /// Split a user@host recipient token into its two lowercased parts. diff --git a/tests/ircv3.rs b/tests/ircv3.rs index a7f32cb..4bde7bb 100644 --- a/tests/ircv3.rs +++ b/tests/ircv3.rs @@ -928,3 +928,96 @@ fn bot_and_account_tags_follow_message_tags() { "WHOIS missing RPL_WHOISBOT: {whois:?}" ); } + +#[test] +fn ban_and_kick_stop_channel_messages() { + // A +b used to affect only JOIN. Kick removed the nick from the names list + // and the ban was announced, but PRIVMSG to the channel kept being delivered. + let addr = start_server(); + let mut op = client(&addr); + register(&mut op, "mod"); + send(&mut op, "JOIN #lobby"); + let _ = drain_until(&mut op, " 366 ", 3); + + let mut biff = client(&addr); + register(&mut biff, "biff"); + send(&mut biff, "JOIN #lobby"); + let _ = drain_until(&mut biff, " 366 ", 3); + + let mut hearer = client(&addr); + register(&mut hearer, "hearer"); + send(&mut hearer, "JOIN #lobby"); + let _ = drain_until(&mut hearer, " 366 ", 3); + let _ = drain_until(&mut op, "hearer", 2); + + send(&mut biff, "PRIVMSG #lobby :before"); + let before = drain_until(&mut hearer, "before", 3); + assert!( + before.contains("before"), + "a member could not speak before the ban: {before:?}" + ); + + send(&mut op, "MODE #lobby +b *!*@*"); + let _ = drain_until(&mut op, "MODE #lobby +b", 2); + + // Still joined, but the ban now blocks both PRIVMSG and NOTICE. NOTICE + // stays silent: no second 404. + send(&mut biff, "PRIVMSG #lobby :banned-still-in"); + send(&mut biff, "NOTICE #lobby :banned-notice"); + send(&mut op, "KICK #lobby biff :flood"); + send(&mut biff, "PRIVMSG #lobby :after-kick"); + + let from_biff = drain_for(&mut biff, 1); + let kicks = from_biff.matches(" 404 ").count(); + assert!( + from_biff.contains("KICK #lobby biff"), + "biff was not kicked: {from_biff:?}" + ); + assert_eq!( + kicks, 2, + "banned speech should be two 404s (PRIVMSG in channel, PRIVMSG after kick) and NOTICE must stay silent: {from_biff:?}" + ); + + let heard = drain_for(&mut hearer, 1); + assert!( + heard.contains("KICK #lobby biff"), + "hearer missed the kick: {heard:?}" + ); + for leaked in ["banned-still-in", "banned-notice", "after-kick"] { + assert!( + !heard.contains(leaked), + "channel still received {leaked} after ban/kick: {heard:?}" + ); + } + + // +e overrides the ban. With no +n, the kicked user can speak again. + send(&mut op, "MODE #lobby +e *!*@*"); + let _ = drain_until(&mut op, "MODE #lobby +e", 2); + send(&mut biff, "PRIVMSG #lobby :excepted"); + let excepted = drain_until(&mut hearer, "excepted", 3); + assert!( + excepted.contains("excepted"), + "+e did not let the outsider speak: {excepted:?}" + ); + + // +n then blocks outsiders and still lets members speak. + send(&mut op, "MODE #lobby +n"); + let _ = drain_until(&mut op, "MODE #lobby +n", 2); + send(&mut biff, "PRIVMSG #lobby :outsider"); + let outsider = drain_until(&mut biff, " 404 ", 3); + assert!( + outsider.contains(" 404 "), + "+n let a non-member speak: {outsider:?}" + ); + send(&mut hearer, "PRIVMSG #lobby :member-speaks"); + let member = drain_until(&mut op, "member-speaks", 3); + assert!( + member.contains("member-speaks"), + "+n silenced a member: {member:?}" + ); + let quiet = drain_for(&mut hearer, 1); + assert!( + !quiet.contains("outsider"), + "+n outsider line was delivered: {quiet:?}" + ); +}