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>
341 lines
11 KiB
Rust
341 lines
11 KiB
Rust
//! 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);
|
|
}
|
|
}
|