//! 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 ) } /// 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 { let cleaned: String = input .chars() .filter(|c| !is_spoofing_format_char(*c)) .map(|c| if c.is_control() { ' ' } else { c }) .collect(); let collapsed = cleaned.split_whitespace().collect::>().join(" "); collapsed.chars().take(NAME_MAX_CHARS).collect() } /// 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); } // --- 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:?}"); } } }