Step 1 of the game-detection feature (game-presence-plan.md): all the pure, I/O-free logic, tested first. - src/game/mod.rs: DetectedGame + stable namespaced ids (steam:730 / exe:hl2_linux, never the mutable name); ManualOverride; the priority resolve() matcher (override -> Steam -> mapped process -> none); the Debouncer (2-on/3-off, immediate bypass for manual override) that stops a flapping detector re-announcing the ~48KB-avatar PeerState; match_processes() over explicit user mappings with a launcher denylist (never guesses a game from an arbitrary process). - src/game/vdf.rs: a real recursive-descent KeyValues/VDF parser (not a name-regex) for appmanifest/.acf, libraryfolders.vdf, registry.vdf — depth-capped, escape-aware, never panics on malformed/truncated input. - src/sanitize.rs: sanitize_game_label (64-char/256-byte cap, wider than the 48-char name cap) sharing the bidi/zero-width cleaning. - src/config.rs: additive game_presence_enabled (opt-in, default OFF), game_backgrounds + game_process_map (BTreeMap, deterministic); background_path generalized to hashed per-game files; explicit legacy-config migration test (load() wipes on any deserialize error). - src/background.rs: game_background_filename (FNV-1a hashed, fs-safe). No wire/protocol change yet; no OS reads yet. 386 lib tests (+28). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
300 lines
12 KiB
Rust
300 lines
12 KiB
Rust
//! 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::<Vec<_>>().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 <name>` 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<usize> {
|
|
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<Segment> {
|
|
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:?}");
|
|
}
|
|
}
|
|
}
|