fix(protocol): add input validation per SRS-094 (TODO-045)

Add validate_and_truncate(), validate_nickname(), validate_message(),
validate_poke_message(), validate_channel_name() with length limits.
Wire to send_text_message in adapter. 14 new validation tests.
This commit is contained in:
Edison Jwa
2026-06-11 14:49:24 +09:00
parent af823da543
commit 0c8efa38f1
2 changed files with 171 additions and 1 deletions
+5 -1
View File
@@ -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,
})
+166
View File
@@ -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<String, &'static str> {
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<String, &'static str> {
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 = "";
// 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);