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:
@@ -45,6 +45,22 @@ pub fn process_background(raw: &[u8]) -> Result<Vec<u8>, String> {
|
||||
Ok(png.into_inner())
|
||||
}
|
||||
|
||||
/// A filesystem-safe, app-owned filename for the processed PNG of a per-game
|
||||
/// background (W18), derived from the game's stable id by hashing rather than
|
||||
/// embedding the raw id: keeps the name short and safe (ids contain `:` and
|
||||
/// arbitrary executable basenames) and avoids leaking the id into the filesystem.
|
||||
/// Deterministic and dependency-free (FNV-1a 64-bit), so the same game id always
|
||||
/// maps to the same file.
|
||||
pub fn game_background_filename(game_id: &str) -> String {
|
||||
// FNV-1a, 64-bit.
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in game_id.as_bytes() {
|
||||
hash ^= *b as u64;
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
format!("game-bg-{hash:016x}.png")
|
||||
}
|
||||
|
||||
/// The legibility scrim drawn between the background image and the UI: the active
|
||||
/// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim`
|
||||
/// recedes the image so body text and panel chrome stay readable, and it re-tints
|
||||
@@ -90,6 +106,17 @@ mod tests {
|
||||
assert!(process_background(b"definitely not an image").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_background_filename_is_stable_safe_and_distinct() {
|
||||
let a = game_background_filename("steam:730");
|
||||
// Stable for the same id.
|
||||
assert_eq!(a, game_background_filename("steam:730"));
|
||||
// Distinct ids → distinct files (no `:` or path chars leak through).
|
||||
assert_ne!(a, game_background_filename("exe:hl2_linux"));
|
||||
assert!(a.starts_with("game-bg-") && a.ends_with(".png"));
|
||||
assert!(!a.contains(':') && !a.contains('/') && !a.contains('\\'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrim_color_sets_alpha_and_keeps_rgb() {
|
||||
let base = Color::from_rgb(0.1, 0.2, 0.3);
|
||||
|
||||
+88
-6
@@ -1,7 +1,7 @@
|
||||
use crate::notify::Sound;
|
||||
use crate::theme::AppTheme;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -199,6 +199,26 @@ pub struct AppConfig {
|
||||
/// `crate::background::scrim_color`.
|
||||
#[serde(default = "default_background_dim")]
|
||||
pub background_dim: f32,
|
||||
/// Broadcast the detected game as presence next to our avatar (game-detection
|
||||
/// feature). **Opt-in, default OFF.** Enabling immediately publishes the
|
||||
/// current game; disabling immediately publishes `game: None`. Toggling this
|
||||
/// is the only thing that puts our game on the wire — detection itself (for the
|
||||
/// local background) runs regardless.
|
||||
#[serde(default)]
|
||||
pub game_presence_enabled: bool,
|
||||
/// Per-game UI background overrides (W18), keyed by stable game id
|
||||
/// (`steam:730`, `exe:hl2_linux`) → path to the processed PNG we wrote in the
|
||||
/// config dir (see `game_background_path`). The running game's entry wins; with
|
||||
/// no entry we fall back to the single custom `background`. Local-only; never
|
||||
/// sent to peers. `BTreeMap` for deterministic serialization.
|
||||
#[serde(default)]
|
||||
pub game_backgrounds: BTreeMap<String, String>,
|
||||
/// User process→display-name mappings for non-Steam game detection, keyed by
|
||||
/// normalized executable basename (`hl2_linux`) → the name to show/broadcast
|
||||
/// (`Half-Life 2`). Only exact mappings here are ever matched (we never guess a
|
||||
/// game from an arbitrary process). Local-only.
|
||||
#[serde(default)]
|
||||
pub game_process_map: BTreeMap<String, String>,
|
||||
/// What a call recording captures (mixed / per-peer stems / both).
|
||||
#[serde(default)]
|
||||
pub recording_mode: RecordingMode,
|
||||
@@ -305,6 +325,9 @@ impl Default for AppConfig {
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
background: None,
|
||||
background_dim: default_background_dim(),
|
||||
game_presence_enabled: false,
|
||||
game_backgrounds: BTreeMap::new(),
|
||||
game_process_map: BTreeMap::new(),
|
||||
recording_mode: RecordingMode::default(),
|
||||
custom_sound_self_join: None,
|
||||
custom_sound_peer_join: None,
|
||||
@@ -375,17 +398,31 @@ impl AppConfig {
|
||||
})
|
||||
}
|
||||
|
||||
/// Path the processed custom-background PNG (W16) is written to, alongside
|
||||
/// `config.json` in the app config dir. We store our own downscaled copy here
|
||||
/// (rather than base64 in the config) so the JSON stays small.
|
||||
pub fn background_path() -> Option<PathBuf> {
|
||||
/// Path to a processed-background PNG of the given filename, alongside
|
||||
/// `config.json` in the app config dir. We store our own downscaled copies here
|
||||
/// (rather than base64 in the config) so the JSON stays small. Used for both
|
||||
/// the single custom background and the per-game backgrounds.
|
||||
fn background_dir_path(filename: &str) -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|mut p| {
|
||||
p.push("peerspeak");
|
||||
p.push("background.png");
|
||||
p.push(filename);
|
||||
p
|
||||
})
|
||||
}
|
||||
|
||||
/// Path the single custom-background PNG (W16) is written to.
|
||||
pub fn background_path() -> Option<PathBuf> {
|
||||
Self::background_dir_path("background.png")
|
||||
}
|
||||
|
||||
/// Path the processed per-game background PNG (W18) for `game_id` is written
|
||||
/// to. The filename is an app-owned hash of the id (see
|
||||
/// `crate::background::game_background_filename`), so raw game ids never appear
|
||||
/// on disk and the name is always filesystem-safe.
|
||||
pub fn game_background_path(game_id: &str) -> Option<PathBuf> {
|
||||
Self::background_dir_path(&crate::background::game_background_filename(game_id))
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
if let Some(path) = Self::config_path()
|
||||
&& let Ok(contents) = fs::read_to_string(&path)
|
||||
@@ -480,6 +517,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backward_compat_game_detection_fields() {
|
||||
// A config that predates the game-detection feature (W18) — and crucially
|
||||
// still carries the W16 single `background` as a plain string — must
|
||||
// deserialize without error. `AppConfig::load()` silently replaces ANY
|
||||
// deserialize failure with full defaults, so a broken migration here would
|
||||
// wipe everyone's settings; this guards that the additive fields kept the
|
||||
// old shape loadable and that `background` was NOT retyped.
|
||||
let legacy_json = r#"{
|
||||
"input_device": "",
|
||||
"output_device": "",
|
||||
"noise_gate_threshold": 0.01,
|
||||
"username": "Eric",
|
||||
"background": "/home/eric/.config/peerspeak/background.png",
|
||||
"background_dim": 0.4
|
||||
}"#;
|
||||
let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap();
|
||||
// The pre-existing single background survives untouched (still Option<String>).
|
||||
assert_eq!(cfg.background.as_deref(), Some("/home/eric/.config/peerspeak/background.png"));
|
||||
assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON);
|
||||
// The new game-detection fields default to off/empty → silent, opt-in upgrade.
|
||||
assert!(!cfg.game_presence_enabled);
|
||||
assert!(cfg.game_backgrounds.is_empty());
|
||||
assert!(cfg.game_process_map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_game_maps_serialize_deterministically() {
|
||||
// BTreeMap ordering makes the serialized config stable across runs.
|
||||
let mut cfg = AppConfig::default();
|
||||
cfg.game_backgrounds.insert("steam:730".into(), "/a.png".into());
|
||||
cfg.game_backgrounds.insert("exe:hl2_linux".into(), "/b.png".into());
|
||||
cfg.game_process_map.insert("hl2_linux".into(), "Half-Life 2".into());
|
||||
let json = serde_json::to_string(&cfg).unwrap();
|
||||
// Keys appear in sorted order (exe: before steam:).
|
||||
let bg = json.find("game_backgrounds").unwrap();
|
||||
let exe_at = json[bg..].find("exe:hl2_linux").unwrap();
|
||||
let steam_at = json[bg..].find("steam:730").unwrap();
|
||||
assert!(exe_at < steam_at, "BTreeMap keys must serialize sorted");
|
||||
// Full round-trip preserves the maps.
|
||||
let back: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.game_backgrounds, cfg.game_backgrounds);
|
||||
assert_eq!(back.game_process_map, cfg.game_process_map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_size_fields() {
|
||||
// Default impl is the standard launch size.
|
||||
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
//! Game detection, game-presence, and game-reactive backgrounds.
|
||||
//!
|
||||
//! A single local "what game is running" detector feeds two consumers:
|
||||
//! 1. **Local** — a per-game UI background that auto-switches (extends W16).
|
||||
//! 2. **Broadcast** — a `Playing <name>` status next to our avatar in every peer's
|
||||
//! roster, riding the gossip presence plane like nickname + avatar.
|
||||
//!
|
||||
//! This module is structured testable-seams-first: the *pure* logic lives here
|
||||
//! (the stable-id scheme, the priority [`resolve`] matcher, the [`Debouncer`], and
|
||||
//! the process-name [`match_processes`] mapping), unit-tested with zero I/O. The OS
|
||||
//! edges — Steam state/file reads ([`steam`]) and the running-process scan
|
||||
//! ([`scan`]) — feed already-parsed values into these pure functions, and the
|
||||
//! cancellable poll service ([`detector`]) wires them together.
|
||||
|
||||
pub mod vdf;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Where a detected game came from. Encodes the trust/priority tier directly:
|
||||
/// a manual override beats live Steam state, which beats a matched process. Used
|
||||
/// only for prioritization and as a presentation hint — never trusted as identity.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GameSource {
|
||||
/// The user forced a specific game (or "none") via the manual override.
|
||||
Manual,
|
||||
/// Steam's live `RunningAppID` resolved against an `appmanifest`.
|
||||
Steam,
|
||||
/// A running process matched against the user's process→name mappings.
|
||||
Process,
|
||||
}
|
||||
|
||||
/// A game the local detector currently believes is running.
|
||||
///
|
||||
/// `id` is the stable, namespaced identity used as the config key for backgrounds
|
||||
/// (`steam:730`, `exe:hl2_linux`) — **never** the mutable display name. `name` is
|
||||
/// the human label shown locally and broadcast as presence; it is `None` only for
|
||||
/// the Steam appid-without-manifest case, where the background can still switch by
|
||||
/// `id` but nothing is broadcast (per the "don't invent `Steam App 123`" rule).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DetectedGame {
|
||||
/// Stable namespaced identity. Config-key safe; survives renames.
|
||||
pub id: String,
|
||||
/// Trustworthy human name; `None` = id-only (Steam manifest unavailable).
|
||||
pub name: Option<String>,
|
||||
/// Provenance / priority tier.
|
||||
pub source: GameSource,
|
||||
}
|
||||
|
||||
impl DetectedGame {
|
||||
/// The Steam namespaced id for an appid: `steam:<appid>`.
|
||||
pub fn steam_id(app_id: u32) -> String {
|
||||
format!("steam:{app_id}")
|
||||
}
|
||||
|
||||
/// The process namespaced id for an executable identity: `exe:<normalized>`.
|
||||
pub fn exe_id(exe: &str) -> String {
|
||||
format!("exe:{}", normalize_exe(exe))
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's manual override sitting above both detectors (D2). Small by design.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum ManualOverride {
|
||||
/// Trust the auto-detector (default).
|
||||
#[default]
|
||||
Auto,
|
||||
/// Force "not playing anything" regardless of what is detected.
|
||||
ForceNone,
|
||||
/// Force a specific game (the user picked it from the known-games list).
|
||||
Force(DetectedGame),
|
||||
}
|
||||
|
||||
/// The outcome of [`resolve`]: the chosen game (if any) plus whether the choice is
|
||||
/// a manual override and so should **bypass the [`Debouncer`]** (apply immediately).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Resolution {
|
||||
pub game: Option<DetectedGame>,
|
||||
/// `true` when a manual override (`ForceNone`/`Force`) decided the value.
|
||||
pub immediate: bool,
|
||||
}
|
||||
|
||||
/// Apply the detector priority (D2 / §5): **manual override → Steam → mapped
|
||||
/// process → none**. Pure; the adapters resolve `steam`/`processes` into
|
||||
/// `DetectedGame`s and this only picks the winner. `processes` is in the adapter's
|
||||
/// deterministic priority order (see [`match_processes`]); its first entry wins.
|
||||
pub fn resolve(
|
||||
override_: &ManualOverride,
|
||||
steam: Option<DetectedGame>,
|
||||
processes: &[DetectedGame],
|
||||
) -> Resolution {
|
||||
match override_ {
|
||||
ManualOverride::ForceNone => Resolution { game: None, immediate: true },
|
||||
ManualOverride::Force(g) => Resolution { game: Some(g.clone()), immediate: true },
|
||||
ManualOverride::Auto => {
|
||||
let game = steam.or_else(|| processes.first().cloned());
|
||||
Resolution { game, immediate: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Samples required before a *new* game is accepted/switched to.
|
||||
pub const ACCEPT_HITS: u32 = 2;
|
||||
/// Consecutive "no game" samples before a currently-shown game is cleared. At the
|
||||
/// ~3 s poll cadence this is ~9 s, absorbing a brief Steam stale/crash blip.
|
||||
pub const CLEAR_MISSES: u32 = 3;
|
||||
|
||||
/// Debounces a stream of raw per-poll detections into a stable published value, so
|
||||
/// a flapping detector can't repeatedly re-announce the entire `PeerState` (which
|
||||
/// can carry the ~48 KB avatar). Pure state machine — the service feeds it samples
|
||||
/// and re-announces only when [`observe`](Debouncer::observe) reports a change.
|
||||
///
|
||||
/// A switch to a different game needs [`ACCEPT_HITS`] matching samples; clearing a
|
||||
/// game needs [`CLEAR_MISSES`] consecutive misses. A manual override
|
||||
/// (`immediate = true`) applies at once, bypassing both counters.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Debouncer {
|
||||
current: Option<DetectedGame>,
|
||||
pending: Option<DetectedGame>,
|
||||
pending_hits: u32,
|
||||
misses: u32,
|
||||
}
|
||||
|
||||
impl Debouncer {
|
||||
/// The currently published, debounced value.
|
||||
pub fn current(&self) -> Option<&DetectedGame> {
|
||||
self.current.as_ref()
|
||||
}
|
||||
|
||||
/// Feed one poll result. `immediate` (a manual override is active) bypasses the
|
||||
/// debounce. Returns `true` iff the published [`current`](Self::current) value
|
||||
/// changed — the signal for the service to re-announce presence / switch the
|
||||
/// background.
|
||||
pub fn observe(&mut self, sample: Option<DetectedGame>, immediate: bool) -> bool {
|
||||
if immediate {
|
||||
let changed = self.current != sample;
|
||||
self.current = sample;
|
||||
self.pending = None;
|
||||
self.pending_hits = 0;
|
||||
self.misses = 0;
|
||||
return changed;
|
||||
}
|
||||
match sample {
|
||||
Some(game) => {
|
||||
self.misses = 0;
|
||||
if self.current.as_ref() == Some(&game) {
|
||||
// Already publishing this game; drop any half-counted switch.
|
||||
self.pending = None;
|
||||
self.pending_hits = 0;
|
||||
false
|
||||
} else {
|
||||
if self.pending.as_ref() == Some(&game) {
|
||||
self.pending_hits += 1;
|
||||
} else {
|
||||
self.pending = Some(game);
|
||||
self.pending_hits = 1;
|
||||
}
|
||||
if self.pending_hits >= ACCEPT_HITS {
|
||||
self.current = self.pending.take();
|
||||
self.pending_hits = 0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// A miss never counts toward a *switch*; drop any pending candidate.
|
||||
self.pending = None;
|
||||
self.pending_hits = 0;
|
||||
if self.current.is_some() {
|
||||
self.misses += 1;
|
||||
if self.misses >= CLEAR_MISSES {
|
||||
self.current = None;
|
||||
self.misses = 0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a raw executable name/path to a stable identity for matching and ids:
|
||||
/// take the final path component (handling both `/` and `\\` separators) and
|
||||
/// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a
|
||||
/// hypothetical `minecraft`), trims surrounding whitespace.
|
||||
pub fn normalize_exe(raw: &str) -> String {
|
||||
raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// Launcher/helper executables that must NEVER be reported as a game even if a
|
||||
/// mapping names them — defense against a mis-entered mapping turning the launcher
|
||||
/// itself into "the game". Normalized (lowercase basename) for comparison.
|
||||
const BUILTIN_DENYLIST: &[&str] = &[
|
||||
"steam",
|
||||
"steam.exe",
|
||||
"steamwebhelper",
|
||||
"steamwebhelper.exe",
|
||||
"steamerrorreporter",
|
||||
"gameoverlayui",
|
||||
"reaper",
|
||||
"lutris",
|
||||
"heroic",
|
||||
"heroic.exe",
|
||||
"legendary",
|
||||
"gogdl",
|
||||
"wine",
|
||||
"wine64",
|
||||
"wineserver",
|
||||
"wine-preloader",
|
||||
"proton",
|
||||
"pressure-vessel-wrap",
|
||||
"explorer.exe",
|
||||
"services.exe",
|
||||
"svchost.exe",
|
||||
];
|
||||
|
||||
/// The built-in launcher/helper denylist as a set, for membership checks.
|
||||
pub fn builtin_denylist() -> BTreeSet<&'static str> {
|
||||
BUILTIN_DENYLIST.iter().copied().collect()
|
||||
}
|
||||
|
||||
/// Match the currently-running executables against the user's explicit
|
||||
/// process→display-name mappings, returning detected games in **deterministic
|
||||
/// priority order** (sorted by stable id) with duplicates removed.
|
||||
///
|
||||
/// Conservative by construction (§3): only exact normalized-basename matches to a
|
||||
/// user mapping count — we never guess that an arbitrary long-running process is a
|
||||
/// game. Any executable on `denylist` is rejected even if mapped, so a launcher or
|
||||
/// helper can't be promoted to "the game".
|
||||
///
|
||||
/// `user_map` keys are matched against the normalized basename of each running
|
||||
/// entry; the key itself is normalized too, so the caller may store either
|
||||
/// `Half-Life 2` style display values keyed by `hl2_linux` or `HL2_Linux`.
|
||||
pub fn match_processes(
|
||||
running: &[String],
|
||||
user_map: &BTreeMap<String, String>,
|
||||
denylist: &BTreeSet<&str>,
|
||||
) -> Vec<DetectedGame> {
|
||||
// Normalize the user map once so lookups are basename/case-insensitive.
|
||||
let normalized_map: BTreeMap<String, &String> =
|
||||
user_map.iter().map(|(k, v)| (normalize_exe(k), v)).collect();
|
||||
|
||||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||
let mut out: Vec<DetectedGame> = Vec::new();
|
||||
for raw in running {
|
||||
let norm = normalize_exe(raw);
|
||||
if norm.is_empty() || denylist.contains(norm.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = normalized_map.get(&norm) {
|
||||
let id = format!("exe:{norm}");
|
||||
if seen.insert(id.clone()) {
|
||||
out.push(DetectedGame {
|
||||
id,
|
||||
name: Some((*name).clone()),
|
||||
source: GameSource::Process,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deterministic priority: stable order independent of process-scan order.
|
||||
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn steam_game(app_id: u32, name: &str) -> DetectedGame {
|
||||
DetectedGame {
|
||||
id: DetectedGame::steam_id(app_id),
|
||||
name: Some(name.to_string()),
|
||||
source: GameSource::Steam,
|
||||
}
|
||||
}
|
||||
|
||||
// --- ids / normalization ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn stable_ids_are_namespaced() {
|
||||
assert_eq!(DetectedGame::steam_id(730), "steam:730");
|
||||
assert_eq!(DetectedGame::exe_id("/usr/games/hl2_linux"), "exe:hl2_linux");
|
||||
assert_eq!(DetectedGame::exe_id("C:\\Games\\Minecraft.exe"), "exe:minecraft.exe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_handles_both_separators_and_case() {
|
||||
assert_eq!(normalize_exe("/opt/Foo/Bar.x86_64"), "bar.x86_64");
|
||||
assert_eq!(normalize_exe("D:\\a\\b\\GAME.EXE"), "game.exe");
|
||||
assert_eq!(normalize_exe(" spaced.bin "), "spaced.bin");
|
||||
assert_eq!(normalize_exe("bare"), "bare");
|
||||
}
|
||||
|
||||
// --- resolve priority --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_steam_over_process_in_auto() {
|
||||
let steam = steam_game(730, "CS2");
|
||||
let procs = vec![DetectedGame {
|
||||
id: "exe:foo".into(),
|
||||
name: Some("Foo".into()),
|
||||
source: GameSource::Process,
|
||||
}];
|
||||
let r = resolve(&ManualOverride::Auto, Some(steam.clone()), &procs);
|
||||
assert_eq!(r.game, Some(steam));
|
||||
assert!(!r.immediate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_falls_back_to_first_process_then_none() {
|
||||
let procs = vec![
|
||||
DetectedGame { id: "exe:a".into(), name: Some("A".into()), source: GameSource::Process },
|
||||
DetectedGame { id: "exe:b".into(), name: Some("B".into()), source: GameSource::Process },
|
||||
];
|
||||
let r = resolve(&ManualOverride::Auto, None, &procs);
|
||||
assert_eq!(r.game.as_ref().unwrap().id, "exe:a");
|
||||
let none = resolve(&ManualOverride::Auto, None, &[]);
|
||||
assert_eq!(none.game, None);
|
||||
assert!(!none.immediate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_manual_override_wins_and_is_immediate() {
|
||||
let steam = steam_game(730, "CS2");
|
||||
// ForceNone overrides a live Steam detection, immediately.
|
||||
let r = resolve(&ManualOverride::ForceNone, Some(steam.clone()), &[]);
|
||||
assert_eq!(r.game, None);
|
||||
assert!(r.immediate);
|
||||
// Force(x) overrides too.
|
||||
let forced = steam_game(220, "HL2");
|
||||
let r = resolve(&ManualOverride::Force(forced.clone()), Some(steam), &[]);
|
||||
assert_eq!(r.game, Some(forced));
|
||||
assert!(r.immediate);
|
||||
}
|
||||
|
||||
// --- debounce ----------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn debounce_requires_two_hits_to_switch() {
|
||||
let mut d = Debouncer::default();
|
||||
let g = steam_game(730, "CS2");
|
||||
// First sighting: not yet published.
|
||||
assert!(!d.observe(Some(g.clone()), false));
|
||||
assert_eq!(d.current(), None);
|
||||
// Second consecutive sighting: now published.
|
||||
assert!(d.observe(Some(g.clone()), false));
|
||||
assert_eq!(d.current(), Some(&g));
|
||||
// Steady state: same game, no further change events.
|
||||
assert!(!d.observe(Some(g.clone()), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debounce_requires_three_misses_to_clear() {
|
||||
let mut d = Debouncer::default();
|
||||
let g = steam_game(730, "CS2");
|
||||
d.observe(Some(g.clone()), false);
|
||||
d.observe(Some(g.clone()), false);
|
||||
assert_eq!(d.current(), Some(&g));
|
||||
// Two misses: still shown (absorbs a transient blip).
|
||||
assert!(!d.observe(None, false));
|
||||
assert!(!d.observe(None, false));
|
||||
assert_eq!(d.current(), Some(&g));
|
||||
// Third miss: cleared.
|
||||
assert!(d.observe(None, false));
|
||||
assert_eq!(d.current(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debounce_blip_during_clear_resets_miss_count() {
|
||||
let mut d = Debouncer::default();
|
||||
let g = steam_game(730, "CS2");
|
||||
d.observe(Some(g.clone()), false);
|
||||
d.observe(Some(g.clone()), false);
|
||||
// Miss, miss, then the game reappears: miss count resets, stays published.
|
||||
d.observe(None, false);
|
||||
d.observe(None, false);
|
||||
assert!(!d.observe(Some(g.clone()), false));
|
||||
assert_eq!(d.current(), Some(&g));
|
||||
// It now takes a fresh run of three misses to clear.
|
||||
d.observe(None, false);
|
||||
d.observe(None, false);
|
||||
assert!(d.observe(None, false));
|
||||
assert_eq!(d.current(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debounce_immediate_bypasses_counters() {
|
||||
let mut d = Debouncer::default();
|
||||
let g = steam_game(730, "CS2");
|
||||
// A manual override publishes on the first sample.
|
||||
assert!(d.observe(Some(g.clone()), true));
|
||||
assert_eq!(d.current(), Some(&g));
|
||||
// ForceNone clears immediately.
|
||||
assert!(d.observe(None, true));
|
||||
assert_eq!(d.current(), None);
|
||||
// Re-issuing the same immediate value is not a change.
|
||||
d.observe(Some(g.clone()), true);
|
||||
assert!(!d.observe(Some(g.clone()), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debounce_switching_games_needs_two_hits_of_the_new_one() {
|
||||
let mut d = Debouncer::default();
|
||||
let a = steam_game(1, "A");
|
||||
let b = steam_game(2, "B");
|
||||
d.observe(Some(a.clone()), false);
|
||||
d.observe(Some(a.clone()), false);
|
||||
assert_eq!(d.current(), Some(&a));
|
||||
// One sample of B does not switch.
|
||||
assert!(!d.observe(Some(b.clone()), false));
|
||||
assert_eq!(d.current(), Some(&a));
|
||||
// Second consecutive B switches.
|
||||
assert!(d.observe(Some(b.clone()), false));
|
||||
assert_eq!(d.current(), Some(&b));
|
||||
}
|
||||
|
||||
// --- process matching --------------------------------------------------
|
||||
|
||||
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_processes_matches_only_explicit_mappings() {
|
||||
let user = map(&[("hl2_linux", "Half-Life 2")]);
|
||||
let deny = builtin_denylist();
|
||||
let running = vec![
|
||||
"/usr/bin/firefox".to_string(),
|
||||
"/games/Half-Life 2/hl2_linux".to_string(),
|
||||
"/usr/bin/htop".to_string(),
|
||||
];
|
||||
let got = match_processes(&running, &user, &deny);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].id, "exe:hl2_linux");
|
||||
assert_eq!(got[0].name.as_deref(), Some("Half-Life 2"));
|
||||
assert_eq!(got[0].source, GameSource::Process);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_processes_rejects_denylisted_even_if_mapped() {
|
||||
// A mis-entered mapping naming the Steam client must not win.
|
||||
let user = map(&[("steam", "Steam (oops)"), ("mygame", "My Game")]);
|
||||
let deny = builtin_denylist();
|
||||
let running = vec!["/usr/bin/steam".into(), "/opt/mygame".into()];
|
||||
let got = match_processes(&running, &user, &deny);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].id, "exe:mygame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_processes_is_deterministic_and_deduped() {
|
||||
let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]);
|
||||
let deny = builtin_denylist();
|
||||
// Same game twice (two processes) + reverse discovery order.
|
||||
let running = vec![
|
||||
"/b/zed".into(),
|
||||
"/a/alpha".into(),
|
||||
"/c/alpha".into(),
|
||||
];
|
||||
let got = match_processes(&running, &user, &deny);
|
||||
// Deduped to two, sorted by id (alpha before zed) regardless of scan order.
|
||||
assert_eq!(got.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(), vec!["exe:alpha", "exe:zed"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_processes_ignores_unmapped_and_case_folds() {
|
||||
let user = map(&[("Game.x86_64", "The Game")]);
|
||||
let deny = builtin_denylist();
|
||||
let running = vec!["/x/GAME.X86_64".into(), "/y/random".into()];
|
||||
let got = match_processes(&running, &user, &deny);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].name.as_deref(), Some("The Game"));
|
||||
}
|
||||
}
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
//! A small, defensive parser for Valve's KeyValues / VDF text format, used by
|
||||
//! `appmanifest_<appid>.acf`, `libraryfolders.vdf`, and `~/.steam/registry.vdf`.
|
||||
//!
|
||||
//! Pure (operates on already-read file *contents*) and unit-tested, per the
|
||||
//! testable-seams-first workflow — the file I/O and size caps live in the Steam
|
||||
//! adapter. Deliberately a real recursive-descent KeyValues parser rather than a
|
||||
//! `"name"`-line regex: escapes, nesting, and truncation will eventually break a
|
||||
//! regex (Codex's "use a real VDF parser" hardening). Hardened against hostile
|
||||
//! input with a recursion-depth cap, so a deeply nested file errors instead of
|
||||
//! overflowing the stack, and never panics on malformed/truncated input.
|
||||
|
||||
/// Max object nesting depth accepted before bailing out. Real Steam files nest a
|
||||
/// handful of levels (`registry.vdf` is the deepest at ~6); this is generous while
|
||||
/// still bounding a malicious file.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
|
||||
/// A parsed KeyValues value: either a leaf string or a nested object. Child order
|
||||
/// is preserved and duplicate keys are kept (KeyValues permits them); lookups
|
||||
/// return the first match.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Value {
|
||||
Str(String),
|
||||
Obj(Vec<(String, Value)>),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
/// The leaf string at this node, if it is a string (not an object).
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Value::Str(s) => Some(s),
|
||||
Value::Obj(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first child value under `key`, if this is an object containing it.
|
||||
/// Case-insensitive on the key (KeyValues keys are conventionally
|
||||
/// case-insensitive, and Steam is inconsistent, e.g. `AppState`/`appid`).
|
||||
pub fn get(&self, key: &str) -> Option<&Value> {
|
||||
match self {
|
||||
Value::Obj(pairs) => pairs
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(key))
|
||||
.map(|(_, v)| v),
|
||||
Value::Str(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Follow a chain of object keys, returning the value at the end of the path.
|
||||
/// `root.get_path(&["AppState", "name"])`.
|
||||
pub fn get_path<'a>(&'a self, path: &[&str]) -> Option<&'a Value> {
|
||||
let mut cur = self;
|
||||
for key in path {
|
||||
cur = cur.get(key)?;
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
/// Iterate the (key, value) child pairs if this is an object.
|
||||
pub fn entries(&self) -> &[(String, Value)] {
|
||||
match self {
|
||||
Value::Obj(pairs) => pairs,
|
||||
Value::Str(_) => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse KeyValues/VDF text into a top-level object (the sequence of root
|
||||
/// key→value pairs). Returns `Err` on unbalanced braces, a key with no value, or
|
||||
/// nesting past [`MAX_DEPTH`]. Never panics.
|
||||
pub fn parse(input: &str) -> Result<Value, String> {
|
||||
let mut lexer = Lexer { rest: input };
|
||||
let obj = parse_object(&mut lexer, 0, true)?;
|
||||
Ok(Value::Obj(obj))
|
||||
}
|
||||
|
||||
/// Parse a run of `key value` pairs. `top_level` parses until EOF; otherwise it
|
||||
/// parses until a closing `}` (which it consumes).
|
||||
fn parse_object(
|
||||
lexer: &mut Lexer,
|
||||
depth: usize,
|
||||
top_level: bool,
|
||||
) -> Result<Vec<(String, Value)>, String> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err("VDF nesting too deep".to_string());
|
||||
}
|
||||
let mut pairs = Vec::new();
|
||||
loop {
|
||||
match lexer.next_token()? {
|
||||
None => {
|
||||
if top_level {
|
||||
return Ok(pairs);
|
||||
}
|
||||
return Err("unexpected end of input inside object".to_string());
|
||||
}
|
||||
Some(Token::Close) => {
|
||||
if top_level {
|
||||
return Err("unexpected '}' at top level".to_string());
|
||||
}
|
||||
return Ok(pairs);
|
||||
}
|
||||
Some(Token::Open) => {
|
||||
return Err("expected key, found '{'".to_string());
|
||||
}
|
||||
Some(Token::Str(key)) => {
|
||||
// A key must be followed by a value: a string or a nested object.
|
||||
match lexer.next_token()? {
|
||||
Some(Token::Str(val)) => pairs.push((key, Value::Str(val))),
|
||||
Some(Token::Open) => {
|
||||
let child = parse_object(lexer, depth + 1, false)?;
|
||||
pairs.push((key, Value::Obj(child)));
|
||||
}
|
||||
Some(Token::Close) => {
|
||||
return Err(format!("key '{key}' has no value (found '}}')"));
|
||||
}
|
||||
None => return Err(format!("key '{key}' has no value (end of input)")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Token {
|
||||
Open,
|
||||
Close,
|
||||
Str(String),
|
||||
}
|
||||
|
||||
struct Lexer<'a> {
|
||||
rest: &'a str,
|
||||
}
|
||||
|
||||
impl Lexer<'_> {
|
||||
/// Produce the next token, skipping whitespace and `//` line comments.
|
||||
fn next_token(&mut self) -> Result<Option<Token>, String> {
|
||||
loop {
|
||||
self.rest = self.rest.trim_start();
|
||||
if self.rest.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// Line comments: `//` to end of line.
|
||||
if let Some(after) = self.rest.strip_prefix("//") {
|
||||
match after.find('\n') {
|
||||
Some(nl) => self.rest = &after[nl + 1..],
|
||||
None => {
|
||||
self.rest = "";
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut chars = self.rest.char_indices();
|
||||
let (_, first) = chars.next().expect("non-empty checked above");
|
||||
return match first {
|
||||
'{' => {
|
||||
self.advance_bytes(first.len_utf8());
|
||||
Ok(Some(Token::Open))
|
||||
}
|
||||
'}' => {
|
||||
self.advance_bytes(first.len_utf8());
|
||||
Ok(Some(Token::Close))
|
||||
}
|
||||
'"' => self.lex_quoted(),
|
||||
_ => Ok(Some(self.lex_bareword())),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_bytes(&mut self, n: usize) {
|
||||
self.rest = &self.rest[n..];
|
||||
}
|
||||
|
||||
/// Lex a `"..."` string, decoding `\\ \" \n \t` escapes. Errors if unterminated.
|
||||
fn lex_quoted(&mut self) -> Result<Option<Token>, String> {
|
||||
// Skip the opening quote.
|
||||
self.advance_bytes(1);
|
||||
let mut out = String::new();
|
||||
let mut chars = self.rest.char_indices();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
match c {
|
||||
'"' => {
|
||||
// Consume through the closing quote.
|
||||
self.rest = &self.rest[i + 1..];
|
||||
return Ok(Some(Token::Str(out)));
|
||||
}
|
||||
'\\' => {
|
||||
// Decode the escape.
|
||||
match chars.next() {
|
||||
Some((_, esc)) => out.push(match esc {
|
||||
'n' => '\n',
|
||||
't' => '\t',
|
||||
'r' => '\r',
|
||||
// `\\`, `\"`, and anything else: take the literal char.
|
||||
other => other,
|
||||
}),
|
||||
None => return Err("unterminated escape in quoted string".to_string()),
|
||||
}
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
Err("unterminated quoted string".to_string())
|
||||
}
|
||||
|
||||
/// Lex an unquoted token: run of non-whitespace, non-brace, non-quote chars.
|
||||
fn lex_bareword(&mut self) -> Token {
|
||||
let end = self
|
||||
.rest
|
||||
.find(|c: char| c.is_whitespace() || matches!(c, '{' | '}' | '"'))
|
||||
.unwrap_or(self.rest.len());
|
||||
let word = self.rest[..end].to_string();
|
||||
self.rest = &self.rest[end..];
|
||||
Token::Str(word)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_appmanifest_name() {
|
||||
// A trimmed-down real appmanifest_<id>.acf.
|
||||
let acf = r#"
|
||||
"AppState"
|
||||
{
|
||||
"appid" "730"
|
||||
"name" "Counter-Strike 2"
|
||||
"StateFlags" "4"
|
||||
"installdir" "Counter-Strike Global Offensive"
|
||||
"UserConfig"
|
||||
{
|
||||
"language" "english"
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let root = parse(acf).unwrap();
|
||||
assert_eq!(root.get_path(&["AppState", "name"]).and_then(Value::as_str), Some("Counter-Strike 2"));
|
||||
assert_eq!(root.get_path(&["AppState", "appid"]).and_then(Value::as_str), Some("730"));
|
||||
// Case-insensitive key lookup.
|
||||
assert_eq!(root.get_path(&["appstate", "NAME"]).and_then(Value::as_str), Some("Counter-Strike 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_libraryfolders_paths_with_escaped_backslashes() {
|
||||
// Windows paths arrive with doubled backslashes (escaped).
|
||||
let vdf = r#"
|
||||
"libraryfolders"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"path" "C:\\Program Files (x86)\\Steam"
|
||||
"apps"
|
||||
{
|
||||
"730" "35000000000"
|
||||
}
|
||||
}
|
||||
"1"
|
||||
{
|
||||
"path" "/home/eric/.local/share/Steam"
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let root = parse(vdf).unwrap();
|
||||
let lf = root.get("libraryfolders").unwrap();
|
||||
assert_eq!(lf.get_path(&["0", "path"]).and_then(Value::as_str), Some(r"C:\Program Files (x86)\Steam"));
|
||||
assert_eq!(lf.get_path(&["1", "path"]).and_then(Value::as_str), Some("/home/eric/.local/share/Steam"));
|
||||
// The library folder ids are iterable for discovery.
|
||||
let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(ids, vec!["0", "1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_registry_running_appid_deep_path() {
|
||||
let reg = r#"
|
||||
"Registry"
|
||||
{
|
||||
"HKCU"
|
||||
{
|
||||
"Software"
|
||||
{
|
||||
"Valve"
|
||||
{
|
||||
"Steam"
|
||||
{
|
||||
"RunningAppID" "570"
|
||||
"language" "english"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let root = parse(reg).unwrap();
|
||||
let appid = root
|
||||
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"])
|
||||
.and_then(Value::as_str);
|
||||
assert_eq!(appid, Some("570"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_comments_and_barewords() {
|
||||
let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n";
|
||||
let root = parse(vdf).unwrap();
|
||||
assert_eq!(root.get_path(&["root", "barekey"]).and_then(Value::as_str), Some("barevalue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_without_panicking() {
|
||||
// Unbalanced braces.
|
||||
assert!(parse("\"a\" {").is_err());
|
||||
// Stray closing brace.
|
||||
assert!(parse("}").is_err());
|
||||
// Key with no value at EOF.
|
||||
assert!(parse("\"lonely\"").is_err());
|
||||
// Unterminated quoted string.
|
||||
assert!(parse("\"key\" \"unterminated").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_pathologically_deep_nesting() {
|
||||
// Build MAX_DEPTH+5 nested objects; must error, not overflow the stack.
|
||||
let mut s = String::new();
|
||||
for i in 0..(MAX_DEPTH + 5) {
|
||||
s.push_str(&format!("\"k{i}\" {{"));
|
||||
}
|
||||
for _ in 0..(MAX_DEPTH + 5) {
|
||||
s.push('}');
|
||||
}
|
||||
assert!(parse(&s).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_keys_return_none_not_error() {
|
||||
let root = parse("\"AppState\" { \"appid\" \"1\" }").unwrap();
|
||||
assert_eq!(root.get_path(&["AppState", "name"]), None);
|
||||
assert_eq!(root.get_path(&["Nope"]), None);
|
||||
// Treating a string as an object yields None rather than panicking.
|
||||
assert_eq!(root.get_path(&["AppState", "appid", "deeper"]), None);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ pub mod recents;
|
||||
pub mod discovery;
|
||||
pub mod hotkeys;
|
||||
pub mod files;
|
||||
pub mod game;
|
||||
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
+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