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());