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:
+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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user