fix(rooms): harden chat-room parsing and TUI state from review findings

Adversarial review of the chat-rooms feature surfaced seven confirmed
issues; all fixed with regression tests:

lib (DoS hardening — a malicious/MITM server could send a tiny frame
with a ~4-billion element count):
- room_list: bound the name/count vector loops by remaining payload.
- join_room: bound the member-list loop the same way.

TUI (soulseek-rs/src/models/rooms.rs, ui):
- a message for a room we already left no longer resurrects its tab
  (route via open_index, not ensure_open).
- closing a tab now clears the unread badge on the room we land on.
- a room-list refresh keeps the highlight on the same room across a
  re-sort (track selection by name, not index).
- Esc in the room list clears a lingering filter before closing, matching
  the title's 'Esc: clear' hint.
- chat log renders one row per line so the newest message is never
  clipped by wrapping.
This commit is contained in:
michel
2026-07-19 20:15:42 +02:00
parent ce75d0a101
commit 9595afe9fb
5 changed files with 155 additions and 12 deletions
@@ -19,6 +19,11 @@ impl MessageHandler<ServerMessage> for JoinRoomHandler {
let user_count = message.read_int32();
let mut users = Vec::new();
for _ in 0..user_count {
// Guard against a hostile user_count outrunning the payload; each
// username is a length-prefixed string (>= 4 bytes).
if message.get_pointer() + 4 > message.get_size() {
break;
}
users.push(message.read_string());
}
let _ = sender.send(ServerMessage::RoomJoined { room, users });
@@ -29,6 +34,26 @@ impl MessageHandler<ServerMessage> for JoinRoomHandler {
mod tests {
use super::*;
#[test]
fn hostile_user_count_does_not_hang() {
// room="" then user_count=u32::MAX with no usernames: must return
// promptly instead of looping ~4 billion times.
let (tx, rx) = std::sync::mpsc::channel();
let mut message = Message::new();
message.write_raw_bytes(vec![0u8; 8]);
message.write_string("");
message.write_int32(u32::MAX);
message.set_pointer(8);
JoinRoomHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::RoomJoined { users, .. }) => {
assert!(users.is_empty());
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn forwards_room_and_member_list() {
let (tx, rx) = std::sync::mpsc::channel();
@@ -27,11 +27,20 @@ pub fn parse_room_list(message: &mut Message) -> Vec<RoomInfo> {
let name_count = message.read_int32();
let mut names = Vec::new();
for _ in 0..name_count {
// Stop once the payload can't hold another field: a bogus (possibly
// hostile) count must not spin us into an OOM allocation loop. A
// 4-byte floor also avoids read_int32 stalling on a 1-3 byte tail.
if message.get_pointer() + 4 > message.get_size() {
break;
}
names.push(message.read_string());
}
let count_count = message.read_int32();
let mut counts = Vec::new();
for _ in 0..count_count {
if message.get_pointer() + 4 > message.get_size() {
break;
}
counts.push(message.read_int32());
}
names
@@ -90,6 +99,17 @@ mod tests {
assert!(parse_room_list(&mut message).is_empty());
}
#[test]
fn hostile_counts_do_not_hang_or_overallocate() {
// A tiny frame claiming ~4 billion names/counts must return promptly
// (bounded by the payload) rather than looping into an OOM.
let mut message = framed(|m| {
m.write_int32(u32::MAX);
});
let rooms = parse_room_list(&mut message);
assert!(rooms.is_empty());
}
#[test]
fn handler_forwards_parsed_rooms() {
let (tx, rx) = std::sync::mpsc::channel();