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:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 = "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);
|
||||
|
||||
Reference in New Issue
Block a user