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,
|
message: String,
|
||||||
target: MessageTarget,
|
target: MessageTarget,
|
||||||
) -> Result<(), ProtocolError> {
|
) -> Result<(), ProtocolError> {
|
||||||
|
let validated = match target {
|
||||||
|
MessageTarget::Poke(_) => crate::dto::validate_poke_message(&message),
|
||||||
|
_ => crate::dto::validate_message(&message),
|
||||||
|
};
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
self.tx
|
self.tx
|
||||||
.send(Request::SendTextMessage {
|
.send(Request::SendTextMessage {
|
||||||
message,
|
message: validated,
|
||||||
target,
|
target,
|
||||||
reply: tx,
|
reply: tx,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,58 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
pub use crate::poke_limiter::PokeStrength;
|
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
|
/// Opaque server-side channel identifier. Internal representation is
|
||||||
/// the upstream u64 but callers must treat it as opaque.
|
/// the upstream u64 but callers must treat it as opaque.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
@@ -674,6 +726,120 @@ mod tests {
|
|||||||
roundtrip_json(&PokeStrength::SuppressedOverflow);
|
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]
|
#[test]
|
||||||
fn client_info_equality_and_clone() {
|
fn client_info_equality_and_clone() {
|
||||||
let a = ClientId(42);
|
let a = ClientId(42);
|
||||||
|
|||||||
Reference in New Issue
Block a user