irc: handle non-UTF-8 input, CTCP VERSION, log outgoing messages

Three fixes:

1. Use read_until + from_utf8_lossy instead of AsyncBufRead::lines(),
   which returns Err on invalid UTF-8. IRC isn't guaranteed UTF-8 —
   Latin-1, Yiddish, etc. would crash the reader loop.

2. Handle CTCP requests (messages wrapped in \x01). Reply to VERSION
   queries so the server stops retrying, and skip CTCP for notification
   generation.

3. Log outgoing messages from the "send" command with append_log() so
   they appear in IRC logs alongside incoming traffic.

Co-Authored-By: ProofOfConcept <poc@bcachefs.org>
This commit is contained in:
ProofOfConcept 2026-03-05 21:15:49 -05:00
parent bea1bd5680
commit 5eb8a4eb6a

View file

@ -263,7 +263,7 @@ async fn connect_and_run(
async fn register_and_read<R: tokio::io::AsyncRead + Unpin>( async fn register_and_read<R: tokio::io::AsyncRead + Unpin>(
state: &SharedIrc, state: &SharedIrc,
config: &IrcConfig, config: &IrcConfig,
reader: BufReader<R>, mut reader: BufReader<R>,
notify_tx: &mpsc::UnboundedSender<Notification>, notify_tx: &mpsc::UnboundedSender<Notification>,
) -> io::Result<()> { ) -> io::Result<()> {
// Register // Register
@ -273,9 +273,15 @@ async fn register_and_read<R: tokio::io::AsyncRead + Unpin>(
s.send_raw(&format!("USER {} 0 * :{}", config.user, config.realname)).await?; s.send_raw(&format!("USER {} 0 * :{}", config.user, config.realname)).await?;
} }
let mut lines = reader.lines(); let mut buf = Vec::new();
while let Some(line) = lines.next_line().await? { loop {
buf.clear();
let n = reader.read_until(b'\n', &mut buf).await?;
if n == 0 { break; }
// IRC is not guaranteed UTF-8 — lossy conversion handles Latin-1 etc.
let line = String::from_utf8_lossy(&buf).trim_end().to_string();
if line.is_empty() { continue; }
let msg = match IrcMessage::parse(&line) { let msg = match IrcMessage::parse(&line) {
Some(m) => m, Some(m) => m,
None => continue, None => continue,
@ -306,6 +312,19 @@ async fn register_and_read<R: tokio::io::AsyncRead + Unpin>(
let text = msg.params.get(1).map(|s| s.as_str()).unwrap_or(""); let text = msg.params.get(1).map(|s| s.as_str()).unwrap_or("");
let nick = msg.nick().unwrap_or("unknown"); let nick = msg.nick().unwrap_or("unknown");
// Handle CTCP requests (wrapped in \x01)
if text.starts_with('\x01') && text.ends_with('\x01') {
let ctcp = &text[1..text.len()-1];
if ctcp.starts_with("VERSION") {
let reply = format!(
"NOTICE {nick} :\x01VERSION poc-daemon 0.4.0\x01"
);
state.borrow_mut().send_raw(&reply).await.ok();
}
// Don't generate notifications for CTCP
continue;
}
// Log the message // Log the message
let log_line = if target.starts_with('#') { let log_line = if target.starts_with('#') {
format!("[{}] <{}> {}", target, nick, text) format!("[{}] <{}> {}", target, nick, text)
@ -453,11 +472,13 @@ pub async fn handle_command(
} }
let target = &args[0]; let target = &args[0];
let msg = args[1..].join(" "); let msg = args[1..].join(" ");
let nick = state.borrow().config.nick.clone();
state state
.borrow_mut() .borrow_mut()
.send_privmsg(target, &msg) .send_privmsg(target, &msg)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
append_log(target, &nick, &msg);
Ok(format!("sent to {target}")) Ok(format!("sent to {target}"))
} }
"status" => { "status" => {