diff --git a/crates/chanora_protocol/src/adapter.rs b/crates/chanora_protocol/src/adapter.rs index 7962ca7..d4c9b74 100644 --- a/crates/chanora_protocol/src/adapter.rs +++ b/crates/chanora_protocol/src/adapter.rs @@ -582,10 +582,14 @@ impl ProtocolClient { message: String, target: MessageTarget, ) -> Result<(), ProtocolError> { + let validated = match target { + MessageTarget::Poke(_) => crate::dto::validate_poke_message(&message), + _ => crate::dto::validate_message(&message), + }; let (tx, rx) = oneshot::channel(); self.tx .send(Request::SendTextMessage { - message, + message: validated, target, reply: tx, }) diff --git a/crates/chanora_protocol/src/dto.rs b/crates/chanora_protocol/src/dto.rs index 84452b2..77e6315 100644 --- a/crates/chanora_protocol/src/dto.rs +++ b/crates/chanora_protocol/src/dto.rs @@ -5,6 +5,58 @@ use serde::{Deserialize, Serialize}; pub use crate::poke_limiter::PokeStrength; +/// TS3 protocol limits for outbound fields. +pub const MAX_NICKNAME_LEN: usize = 30; +pub const MAX_MESSAGE_LEN: usize = 1024; +pub const MAX_POKE_LEN: usize = 100; +pub const MAX_CHANNEL_NAME_LEN: usize = 40; + +/// Truncate `s` to at most `max_len` UTF-8 characters, splitting at a +/// char boundary if needed. Returns the (possibly shortened) string. +pub fn validate_and_truncate(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + return s.to_string(); + } + // Find the last char boundary at or before max_len. + let mut end = max_len; + while !s.is_char_boundary(end) { + end -= 1; + } + s[..end].to_string() +} + +/// Validate a nickname: trim whitespace, reject empty, truncate to +/// [`MAX_NICKNAME_LEN`]. +pub fn validate_nickname(nick: &str) -> Result { + let trimmed = nick.trim(); + if trimmed.is_empty() { + return Err("nickname must not be empty"); + } + Ok(validate_and_truncate(trimmed, MAX_NICKNAME_LEN)) +} + +/// Validate a chat message: truncate to [`MAX_MESSAGE_LEN`]. Empty +/// messages are allowed (poke-without-message is valid per DEC-037). +pub fn validate_message(msg: &str) -> String { + validate_and_truncate(msg, MAX_MESSAGE_LEN) +} + +/// Validate a poke message: truncate to [`MAX_POKE_LEN`]. Empty +/// messages are allowed. +pub fn validate_poke_message(msg: &str) -> String { + validate_and_truncate(msg, MAX_POKE_LEN) +} + +/// Validate a channel name: trim whitespace, reject empty, truncate +/// to [`MAX_CHANNEL_NAME_LEN`]. +pub fn validate_channel_name(name: &str) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("channel name must not be empty"); + } + Ok(validate_and_truncate(trimmed, MAX_CHANNEL_NAME_LEN)) +} + /// Opaque server-side channel identifier. Internal representation is /// the upstream u64 but callers must treat it as opaque. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -674,6 +726,120 @@ mod tests { roundtrip_json(&PokeStrength::SuppressedOverflow); } + // ── validation tests ─────────────────────────────────────────── + + #[test] + fn validate_and_truncate_short_string_unchanged() { + assert_eq!(validate_and_truncate("hello", 10), "hello"); + } + + #[test] + fn validate_and_truncate_exact_boundary() { + assert_eq!(validate_and_truncate("12345", 5), "12345"); + } + + #[test] + fn validate_and_truncate_truncates_ascii() { + assert_eq!(validate_and_truncate("hello world", 5), "hello"); + } + + #[test] + fn validate_and_truncate_respects_char_boundary() { + // é is 2 bytes; truncating at byte 1 would panic without + // char-boundary logic. + let s = "aé"; + // s.len() == 3 (a=1, é=2). max_len=2 → must drop é. + assert_eq!(validate_and_truncate(s, 2), "a"); + } + + #[test] + fn validate_and_truncate_emoji_multibyte() { + let s = "🎮🎮🎮"; + // Each emoji is 4 bytes. max_len=5 → only first emoji (4 bytes). + assert_eq!(validate_and_truncate(s, 5), "🎮"); + } + + #[test] + fn validate_and_truncate_empty() { + assert_eq!(validate_and_truncate("", 10), ""); + } + + #[test] + fn validate_nickname_ok() { + assert_eq!(validate_nickname("Alice").unwrap(), "Alice"); + } + + #[test] + fn validate_nickname_trims_whitespace() { + assert_eq!(validate_nickname(" Bob ").unwrap(), "Bob"); + } + + #[test] + fn validate_nickname_rejects_empty() { + assert!(validate_nickname("").is_err()); + assert!(validate_nickname(" ").is_err()); + } + + #[test] + fn validate_nickname_truncates_long() { + let long = "A".repeat(100); + let result = validate_nickname(&long).unwrap(); + assert!(result.len() <= MAX_NICKNAME_LEN); + assert_eq!(result.len(), MAX_NICKNAME_LEN); + } + + #[test] + fn validate_message_short_unchanged() { + assert_eq!(validate_message("hi"), "hi"); + } + + #[test] + fn validate_message_empty_allowed() { + assert_eq!(validate_message(""), ""); + } + + #[test] + fn validate_message_truncates_long() { + let long = "x".repeat(2000); + let result = validate_message(&long); + assert!(result.len() <= MAX_MESSAGE_LEN); + } + + #[test] + fn validate_poke_message_truncates_to_shorter_limit() { + let long = "y".repeat(200); + let result = validate_poke_message(&long); + assert!(result.len() <= MAX_POKE_LEN); + } + + #[test] + fn validate_poke_message_empty_allowed() { + assert_eq!(validate_poke_message(""), ""); + } + + #[test] + fn validate_channel_name_ok() { + assert_eq!(validate_channel_name("General").unwrap(), "General"); + } + + #[test] + fn validate_channel_name_trims_whitespace() { + assert_eq!(validate_channel_name(" AFK ").unwrap(), "AFK"); + } + + #[test] + fn validate_channel_name_rejects_empty() { + assert!(validate_channel_name("").is_err()); + assert!(validate_channel_name(" ").is_err()); + } + + #[test] + fn validate_channel_name_truncates_long() { + let long = "C".repeat(100); + let result = validate_channel_name(&long).unwrap(); + assert!(result.len() <= MAX_CHANNEL_NAME_LEN); + } + #[test] fn client_info_equality_and_clone() { let a = ClientId(42);