//! Sanitizers for untrusted peer-supplied strings shared across layers. //! //! Peer display names ride the gossip presence plane (`PeerState.name`), which is //! untrusted and spoofable, yet they're rendered directly in the roster. This //! module cleans a name at the gossip ingest point so every downstream consumer //! gets a safe value (security finding S4). Chat text has its own sanitizer in //! the UI layer (`app::sanitize_chat`). /// Max characters kept for a peer's display name after sanitizing. Names are /// short labels, so a tight cap both prevents UI/layout/memory abuse and keeps /// the roster readable. pub const NAME_MAX_CHARS: usize = 48; /// Unicode *format* characters (`General_Category=Cf`) that can spoof or garble a /// rendered name even though they are NOT caught by [`char::is_control`]: /// bidirectional overrides/isolates (text-direction spoofing) and /// zero-width / BOM characters (invisible, can hide or fake content). Listed /// explicitly so the sanitizer stays dependency-free (std exposes no category /// query). Stripped outright rather than replaced. fn is_spoofing_format_char(c: char) -> bool { matches!(c, '\u{200B}'..='\u{200F}' // zero-width space, ZWNJ, ZWJ, LRM, RLM | '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO (bidi overrides) | '\u{2060}'..='\u{2064}' // word joiner .. invisible plus | '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI (bidi isolates) | '\u{FEFF}' // BOM / zero-width no-break space ) } /// Max characters kept for a broadcast game-presence label after sanitizing /// (game titles run longer than nicknames, so a wider cap than [`NAME_MAX_CHARS`]), /// bounded additionally by [`GAME_LABEL_MAX_BYTES`] so a multibyte-heavy string /// can't blow the presence frame. pub const GAME_LABEL_MAX_CHARS: usize = 64; /// Max UTF-8 bytes kept for a broadcast game-presence label, applied on top of /// [`GAME_LABEL_MAX_CHARS`]. Caps the on-wire size regardless of scalar width. pub const GAME_LABEL_MAX_BYTES: usize = 256; /// Shared cleaning for untrusted short labels: strip bidi / zero-width spoofing /// format characters, turn control characters into spaces, collapse any whitespace /// run to a single space, and trim the ends. Length capping is the caller's job. fn clean_label(input: &str) -> String { let cleaned: String = input .chars() .filter(|c| !is_spoofing_format_char(*c)) .map(|c| if c.is_control() { ' ' } else { c }) .collect(); cleaned.split_whitespace().collect::>().join(" ") } /// Sanitize an untrusted peer display name for safe rendering. Strips bidi / /// zero-width format characters, turns control characters into spaces, collapses /// any whitespace run to a single space, trims the ends, and caps the length at /// [`NAME_MAX_CHARS`]. Returns `""` if nothing usable remains (callers may /// substitute a placeholder such as a short id). pub fn sanitize_name(input: &str) -> String { clean_label(input).chars().take(NAME_MAX_CHARS).collect() } /// Sanitize an untrusted game-presence label (the `Playing ` status that /// rides the gossip presence plane). Same spoof/control cleaning as /// [`sanitize_name`], but capped at [`GAME_LABEL_MAX_CHARS`] scalars AND /// [`GAME_LABEL_MAX_BYTES`] bytes. Apply on BOTH the outgoing label we detect and /// any incoming peer label. Returns `""` if nothing usable remains (no broadcast). pub fn sanitize_game_label(input: &str) -> String { let mut out = String::new(); for c in clean_label(input).chars().take(GAME_LABEL_MAX_CHARS) { if out.len() + c.len_utf8() > GAME_LABEL_MAX_BYTES { break; } out.push(c); } out } /// A piece of a chat message after URL detection: literal text or a link. #[derive(Debug, PartialEq, Eq, Clone)] pub enum Segment { /// Plain text to render as-is. Text(String), /// A detected URL to render as a clickable link (also its href). Link(String), } /// Trailing characters commonly adjacent to a URL in prose that should NOT be /// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly). fn is_url_trailing_punct(c: char) -> bool { matches!(c, '.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}' | '>' | '"' | '\'') } /// Find the byte index of the earliest `http://` or `https://` scheme in `s`, /// scanning only on char boundaries so slicing is always safe. fn find_scheme(s: &str) -> Option { s.char_indices().find_map(|(i, _)| { let tail = &s[i..]; (tail.starts_with("http://") || tail.starts_with("https://")).then_some(i) }) } /// Split an (already chat-sanitized) message into plain-text and URL [`Segment`]s /// for rendering. **Conservative on purpose:** only `http://` / `https://` runs /// are treated as links, each ending at the first whitespace, with trailing prose /// punctuation peeled back into the following text. Concatenating every segment's /// inner string reproduces the input exactly (no characters added or dropped), so /// it's purely a presentational split. Linkify AFTER sanitizing so control/format /// chars are already gone (the URL can't smuggle them). Pure → unit-testable. pub fn linkify(input: &str) -> Vec { let mut out = Vec::new(); let mut rest = input; while !rest.is_empty() { let Some(start) = find_scheme(rest) else { out.push(Segment::Text(rest.to_string())); break; }; if start > 0 { out.push(Segment::Text(rest[..start].to_string())); } let after = &rest[start..]; let end = after.find(char::is_whitespace).unwrap_or(after.len()); let candidate = &after[..end]; // Peel trailing punctuation back out of the link. let url = candidate.trim_end_matches(is_url_trailing_punct); out.push(Segment::Link(url.to_string())); // Continue past just the URL; any peeled punctuation + the rest (incl. the // whitespace) is reconsidered as ordinary text on the next iteration. rest = &after[url.len()..]; } out } #[cfg(test)] mod tests { use super::*; #[test] fn keeps_ordinary_names_including_unicode() { assert_eq!(sanitize_name("Alice"), "Alice"); assert_eq!(sanitize_name("héllo 世界"), "héllo 世界"); // Emoji are not format/control chars — kept. assert_eq!(sanitize_name("🎙 Mike"), "🎙 Mike"); } #[test] fn strips_control_chars_and_collapses_whitespace() { // NUL, CR/LF, TAB, and ANSI ESC are control chars → become spaces, then // collapse; ends trim. assert_eq!(sanitize_name(" a\u{0}b\r\nc\td\u{1b}[31m "), "a b c d [31m"); // A name that is only control/whitespace cleans to empty. assert_eq!(sanitize_name("\u{0}\r\n\t "), ""); } #[test] fn strips_bidi_and_zero_width_format_chars() { // Right-to-left override (classic name-spoof char) is removed entirely. assert_eq!(sanitize_name("admin\u{202E}txt"), "admintxt"); // Zero-width space / joiner / BOM removed. assert_eq!(sanitize_name("ab\u{200B}cd"), "abcd"); assert_eq!(sanitize_name("\u{FEFF}name"), "name"); // Bidi isolates removed. assert_eq!(sanitize_name("x\u{2066}y\u{2069}z"), "xyz"); } #[test] fn caps_length() { let long = "n".repeat(NAME_MAX_CHARS + 500); assert_eq!(sanitize_name(&long).chars().count(), NAME_MAX_CHARS); } // --- sanitize_game_label ---------------------------------------------- #[test] fn game_label_keeps_ordinary_titles_and_strips_spoofing() { assert_eq!(sanitize_game_label("Half-Life 2"), "Half-Life 2"); // Same spoof/control cleaning as names. assert_eq!(sanitize_game_label("Doom\u{202E}txt"), "Doomtxt"); assert_eq!(sanitize_game_label("a\u{0}b\r\nc"), "a b c"); } #[test] fn game_label_caps_chars_wider_than_names() { // A game label keeps more than a name's 48 (up to 64), so a title between // the two caps survives in full. let mid = "g".repeat(56); assert_eq!(sanitize_game_label(&mid).chars().count(), 56); let long = "g".repeat(GAME_LABEL_MAX_CHARS + 100); assert_eq!(sanitize_game_label(&long).chars().count(), GAME_LABEL_MAX_CHARS); } #[test] fn game_label_caps_bytes_for_multibyte_titles() { // Each '世' is 3 bytes; 64 of them = 192 bytes (under 256) → all kept. let cjk = "世".repeat(GAME_LABEL_MAX_CHARS); let out = sanitize_game_label(&cjk); assert_eq!(out.chars().count(), GAME_LABEL_MAX_CHARS); assert!(out.len() <= GAME_LABEL_MAX_BYTES); // Emoji are 4 bytes; the byte cap bites before the char cap (256/4 = 64, // but the leading clean keeps them as a run) — never exceeds the byte cap. let emoji = "🎮".repeat(GAME_LABEL_MAX_CHARS); let out = sanitize_game_label(&emoji); assert!(out.len() <= GAME_LABEL_MAX_BYTES); assert!(out.chars().all(|c| c == '🎮')); } #[test] fn game_label_empty_when_nothing_usable() { assert_eq!(sanitize_game_label("\u{0}\r\n\t "), ""); } // --- linkify ----------------------------------------------------------- /// Concatenating every segment's inner text must reproduce the input exactly. fn reassemble(segs: &[Segment]) -> String { segs.iter() .map(|s| match s { Segment::Text(t) | Segment::Link(t) => t.as_str(), }) .collect() } #[test] fn linkify_plain_text_has_no_links() { let segs = linkify("just a normal message, nothing here"); assert_eq!(segs, vec![Segment::Text("just a normal message, nothing here".into())]); } #[test] fn linkify_detects_http_and_https() { assert_eq!( linkify("see http://example.com now"), vec![ Segment::Text("see ".into()), Segment::Link("http://example.com".into()), Segment::Text(" now".into()), ] ); assert_eq!( linkify("https://a.test/path?q=1"), vec![Segment::Link("https://a.test/path?q=1".into())] ); } #[test] fn linkify_peels_trailing_punctuation() { // Sentence-final period is not part of the link. assert_eq!( linkify("go to https://x.com."), vec![ Segment::Text("go to ".into()), Segment::Link("https://x.com".into()), Segment::Text(".".into()), ] ); // Parenthesized URL. assert_eq!( linkify("(https://x.com)"), vec![ Segment::Text("(".into()), Segment::Link("https://x.com".into()), Segment::Text(")".into()), ] ); } #[test] fn linkify_handles_multiple_urls() { let segs = linkify("a http://one.com b https://two.com c"); assert_eq!( segs, vec![ Segment::Text("a ".into()), Segment::Link("http://one.com".into()), Segment::Text(" b ".into()), Segment::Link("https://two.com".into()), Segment::Text(" c".into()), ] ); } #[test] fn linkify_only_matches_http_schemes() { // Non-web schemes and bare domains are NOT linkified (conservative). let segs = linkify("email me@x.com or ftp://x.com or visit x.com"); assert_eq!(segs, vec![Segment::Text("email me@x.com or ftp://x.com or visit x.com".into())]); } #[test] fn linkify_preserves_input_exactly() { for msg in [ "", "no urls at all", "http://a.com", "pre http://a.com/x?y=z&w=1 mid https://b.org/p, end!", "weird))) http://c.com]]] tail", "unicode 世界 http://d.com/路径 more 世界", ] { assert_eq!(reassemble(&linkify(msg)), msg, "roundtrip failed for {msg:?}"); } } }