feat(game): pure detection seams — matcher, debouncer, VDF parser, config
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>
This commit is contained in:
+78
-7
@@ -27,19 +27,50 @@ fn is_spoofing_format_char(c: char) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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::<Vec<_>>().join(" ");
|
||||
collapsed.chars().take(NAME_MAX_CHARS).collect()
|
||||
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.
|
||||
@@ -135,6 +166,46 @@ mod tests {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user