From b436b57f13da7c47fc80116ec3fa65e4ff8cabf8 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Fri, 5 Jun 2026 23:02:08 -0400 Subject: [PATCH] feat: remember last nickname + sanitize chat input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remember last nickname: a new serde-default AppConfig.username field is pre-filled into the launch-screen nickname field, and saved when a room is joined or created (i.e. when the name is actually used), so it carries across launches. Sanitize chat: a pure sanitize_chat() drops control characters (ANSI escapes, NUL, stray CR/LF/TAB), collapses whitespace runs to single spaces, trims, and caps length (2000 chars). Applied to our outgoing text on submit AND to incoming peer messages on receive — peer content is untrusted, so the sender's name and text are both sanitized before display; empty-after-sanitize messages are dropped. Unit tests for sanitize_chat (control/whitespace/unicode/empty + length cap) and a config backward-compat assertion for username. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++++--- src/config.rs | 10 +++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 63bbc7d..fb0549d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -226,7 +226,8 @@ impl Default for AppState { Self { - name: "Peer".to_string(), + // Pre-fill the nickname with the last one used (or "Peer" by default). + name: config.username.clone(), ticket_input: "".to_string(), status_message: "Ready to connect".to_string(), self_id: "".to_string(), @@ -333,6 +334,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { let output_device = state.selected_output.as_ref().map(|d| d.name.clone()); if !state.ticket_input.is_empty() { state.status_message = "Joining room...".to_string(); + // Remember this nickname for next launch. + state.config.username = state.name.clone(); + state.config.save(); // Core releases any standalone mic monitor on join. state.mic_test_active = false; let _ = state.controller.send(CoreCommand::Join { @@ -348,6 +352,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { let input_device = state.selected_input.as_ref().map(|d| d.name.clone()); let output_device = state.selected_output.as_ref().map(|d| d.name.clone()); state.status_message = "Creating room...".to_string(); + // Remember this nickname for next launch. + state.config.username = state.name.clone(); + state.config.save(); // Core releases any standalone mic monitor on join. state.mic_test_active = false; let _ = state.controller.send(CoreCommand::Join { @@ -457,7 +464,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.status_message = format!("Saved recording → {path}"); } UiEvent::ChatMessage { name, text } => { - push_chat(&mut state.chat_messages, ChatEntry { name, text, mine: false }); + // Incoming peer content is untrusted — sanitize name + text. + let text = sanitize_chat(&text); + if !text.is_empty() { + let name = sanitize_chat(&name); + push_chat(&mut state.chat_messages, ChatEntry { name, text, mine: false }); + } } UiEvent::Error(err) => { state.status_message = format!("Error: {}", err); @@ -580,7 +592,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { } } AppMessage::ChatSubmit => { - let text = state.chat_input.trim().to_string(); + let text = sanitize_chat(&state.chat_input); if !text.is_empty() { // Local echo (gossip suppresses our own author, so it won't come back). push_chat(&mut state.chat_messages, ChatEntry { @@ -673,6 +685,24 @@ fn format_duration(total_secs: u64) -> String { } } +/// Max characters kept for a single chat message after sanitizing. +const CHAT_MSG_MAX_CHARS: usize = 2000; + +/// Sanitize a chat string for display, applied to BOTH our outgoing text and +/// incoming text from peers (peer input is untrusted — a buggy/malicious sender +/// could include control characters or an enormous payload). Drops control +/// characters (ANSI escapes, NUL, stray CR/LF/TAB), collapses any whitespace run +/// to a single space, trims the ends, and caps the length. Returns "" for input +/// that is empty after cleaning (the caller skips empty messages). +fn sanitize_chat(input: &str) -> String { + let no_control: String = input + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + let collapsed = no_control.split_whitespace().collect::>().join(" "); + collapsed.chars().take(CHAT_MSG_MAX_CHARS).collect() +} + /// Append a chat line, trimming the oldest once history exceeds the cap so a long /// call can't grow the buffer without bound. fn push_chat(messages: &mut Vec, entry: ChatEntry) { @@ -1692,6 +1722,29 @@ mod tests { assert_eq!(clamp_chat_height(window_h, window_h), window_h - 300.0); } + #[test] + fn sanitize_chat_strips_control_chars_and_collapses_whitespace() { + use super::sanitize_chat; + // Plain text is unchanged. + assert_eq!(sanitize_chat("hello world"), "hello world"); + // Leading/trailing whitespace trimmed; interior runs collapsed. + assert_eq!(sanitize_chat(" hi there "), "hi there"); + // Control chars (NUL, CR, LF, TAB, ANSI ESC) become spaces, then collapse. + // The ESC of an ANSI sequence is stripped; the printable "[31m" remains inert text. + assert_eq!(sanitize_chat("a\u{0}b\r\nc\td\u{1b}[31m"), "a b c d [31m"); + // An all-control / all-whitespace message sanitizes to empty. + assert_eq!(sanitize_chat("\u{0}\r\n\t "), ""); + // Unicode/emoji text is preserved. + assert_eq!(sanitize_chat("héllo 🎙 世界"), "héllo 🎙 世界"); + } + + #[test] + fn sanitize_chat_caps_length() { + use super::{sanitize_chat, CHAT_MSG_MAX_CHARS}; + let long = "x".repeat(CHAT_MSG_MAX_CHARS + 500); + assert_eq!(sanitize_chat(&long).chars().count(), CHAT_MSG_MAX_CHARS); + } + #[test] fn divider_clamps_are_finite_on_a_tiny_window() { // A window smaller than the reserves must not produce NaN/inverted ranges. diff --git a/src/config.rs b/src/config.rs index 7b2ccc5..5bbcbdf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -44,6 +44,10 @@ fn default_volume() -> f32 { 1.0 } +fn default_username() -> String { + "Peer".to_string() +} + fn default_participants_width() -> f32 { 540.0 } @@ -54,6 +58,9 @@ fn default_chat_height() -> f32 { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct AppConfig { + /// Last nickname used to join/create a room; pre-filled on the launch screen. + #[serde(default = "default_username")] + pub username: String, pub input_device: String, pub output_device: String, pub noise_gate_threshold: f32, @@ -98,6 +105,7 @@ pub struct AppConfig { impl Default for AppConfig { fn default() -> Self { Self { + username: default_username(), input_device: "".to_string(), output_device: "".to_string(), noise_gate_threshold: 0.01, @@ -176,6 +184,8 @@ mod tests { // Configs predating the draggable dividers must load the default sizes. assert_eq!(deserialized.participants_width, 540.0); assert_eq!(deserialized.chat_height, 180.0); + // Configs predating the remembered username load the default nickname. + assert_eq!(deserialized.username, "Peer"); assert!(deserialized.custom_sound_self_join.is_none()); assert!(deserialized.custom_sound_peer_join.is_none()); assert!(deserialized.custom_sound_peer_leave.is_none());