//! A small, defensive parser for Valve's KeyValues / VDF text format, used by //! `appmanifest_.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 { 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, 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, 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, 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_.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); } }