irc: split PRIVMSG on embedded newlines + widen host overhead

Two fixes to send_privmsg, both surfaced by correspondents reporting
truncated messages:

1. Multi-line content (code blocks, formatted text) sent as a single
   PRIVMSG was being truncated at the first '\n' by the IRC server —
   newlines are end-of-command markers. Split the message on newlines
   and send each line as its own PRIVMSG; skip empty lines since most
   servers reject empty PRIVMSGs.

2. Overhead computation assumed a host field of 63 bytes. OFTC's
   cloaked hostmasks can be longer, occasionally pushing the server-
   prepended prefix past 512 bytes and causing silent truncation.
   Raise the host budget to 80 and align the formula with the actual
   ':nick!~nick@host' prefix shape.

Also extended the word-boundary lookback from a fixed 10 chars to
max_msg / 4 — dense content (code) rarely had a space within 10 chars
of the length cap, so we were falling back to the char boundary and
splitting mid-word. Checking bytes[j-1] for a space (instead of
bytes[j]) drops leading whitespace from the rest-fragment.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-24 11:53:31 -04:00
parent 85799587cc
commit 5908b837e8

View file

@ -237,11 +237,19 @@ impl State {
async fn send_privmsg(&mut self, target: &str, msg: &str) -> io::Result<()> { async fn send_privmsg(&mut self, target: &str, msg: &str) -> io::Result<()> {
// Send PRIVMSG, which is used for both private and channel messages. // Send PRIVMSG, which is used for both private and channel messages.
// Splits into multiple fragments if necessary. // Splits into multiple fragments if necessary.
// IRC max line = 512 bytes including CRLF. The server prepends //
// Two constraints:
// 1. IRC max line = 512 bytes including CRLF. The server prepends
// our prefix when relaying: ":nick!~user@host PRIVMSG target :msg\r\n" // our prefix when relaying: ":nick!~user@host PRIVMSG target :msg\r\n"
// So per-PRIVMSG message content must fit in 512 - overhead.
// 2. Embedded '\n' in the message would be interpreted by the
// server as an end-of-command marker, truncating us. Split
// on newlines first and send each line as its own PRIVMSG.
//
// User is often ~nick (nick_len + 1). Host is up to 63 bytes. // User is often ~nick (nick_len + 1). Host is up to 63 bytes.
// Cloaked OFTC hosts can be longer - pad the budget.
let nick_len = self.config.nick.len(); let nick_len = self.config.nick.len();
let overhead = 1 + nick_len + 2 + nick_len + 1 + 63 let overhead = 1 + nick_len + 1 + (nick_len + 1) + 1 + 80
+ " PRIVMSG ".len() + target.len() + " :".len() + 2; + " PRIVMSG ".len() + target.len() + " :".len() + 2;
let max_msg = 512_usize.saturating_sub(overhead); let max_msg = 512_usize.saturating_sub(overhead);
@ -249,24 +257,34 @@ impl State {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "target too long")); return Err(io::Error::new(io::ErrorKind::InvalidInput, "target too long"));
} }
// Split on UTF-8 char boundaries for line in msg.split('\n') {
let mut remaining = msg; let mut remaining = line;
while !remaining.is_empty() { // Empty lines (blank paragraph breaks) can't be sent as empty
// PRIVMSGs - most IRC servers reject them. Skip.
if remaining.is_empty() { continue; }
loop {
let split_at = if remaining.len() <= max_msg { let split_at = if remaining.len() <= max_msg {
remaining.len() remaining.len()
} else { } else {
// Find last char boundary at or before max_msg // Find last char boundary at or before max_msg.
let mut i = max_msg; let mut i = max_msg;
while i > 0 && !remaining.is_char_boundary(i) { i -= 1; } while i > 0 && !remaining.is_char_boundary(i) { i -= 1; }
// To avoid splitting mid-word, see if there was a space recently // Prefer splitting at a word boundary - look back up to
// max_msg/4 chars for a space. With dense content (code)
// we may not find one; fall back to the char boundary.
let lookback = max_msg / 4;
let bytes = remaining.as_bytes();
let mut j = i; let mut j = i;
while j > 1 && j > i-10 && remaining.as_bytes()[j] != b' ' { j -= 1; } while j > 0 && (i - j) < lookback && bytes[j - 1] != b' ' {
if remaining.as_bytes()[j] == b' ' { j } j -= 1;
else if i == 0 { max_msg } else { i } }
if j > 0 && bytes[j - 1] == b' ' { j } else { i }
}; };
let (chunk, rest) = remaining.split_at(split_at); let (chunk, rest) = remaining.split_at(split_at);
self.send_raw(&format!("PRIVMSG {target} :{chunk}")).await?; self.send_raw(&format!("PRIVMSG {target} :{chunk}")).await?;
remaining = rest; remaining = rest;
if remaining.is_empty() { break; }
}
} }
Ok(()) Ok(())
} }