diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-17 00:53:20 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-17 00:53:20 -0500 |
| commit | 06c396fd27f55929b40161e587cb9b596b549f3f (patch) | |
| tree | 40fdb465d2fe6e7d4487bbd237582868c8332459 /libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/binary | |
| parent | e583d6dda3cde9e8bccee313fe1ad69634593708 (diff) | |
Symlink saves, rank prefixes by Steam app type, ntsync safeguards
- Replace copy-in/copy-out save deployment with a direct symlink from the
prefix's __MO_Saves to the profile's saves/. Writes land in the profile
immediately; afterRun reverts the symlink + prefix INI so a vanilla
launch outside MO2 uses the default Saves dir.
- Parse Steam's binary appinfo.vdf (v41) via a new Rust FFI crate backed
by steam-vdf-parser. Rank detected games by common.type so real Games
beat Tools/Editors when multiple prefixes share a My Games folder name
(fixes Creation Kit shadowing Skyrim SE). Top-ranked candidate
overwrites stale symlinks from earlier runs.
- Switch bundled Steam Linux Runtime downloader from sniper (steamrt3)
to steamrt4 so Proton 11+ works out of the box. steamrt4 omits xrandr
so we inject it from the Debian x11-xserver-utils .deb into a
dedicated dir and prepend it to PATH via `env PATH=...` inside the
pressure-vessel container (PATH env doesn't propagate).
- Auto-kill stale wineboot/wineserver/pv-adverb processes still bound
to the prefix before starting a new wineboot -u; match by
/proc/<pid>/environ WINEPREFIX / STEAM_COMPAT_DATA_PATH since Wine
cmdlines are Windows-style. Same sweep runs in destroyPrefix so a
delete actually frees file handles.
- Blacklist Proton 11 (Steam-bundled) — its current Wine tree deadlocks
during wineboot -u on modern kernels via ntsync / futex_wait_multiple
races. Use "waitforexitandrun" (not "run") for prefix init so
use_sessions=1 Protons don't fork into a persistent session manager.
- Drop WINEDEBUG=-all during prefix init so wineboot progress is
visible. Suppress focus-stealing by setting WA_ShowWithoutActivating
on the PrefixSetupDialog and SLR download progress dialogs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/binary')
4 files changed, 1660 insertions, 0 deletions
diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/binary/byte_reader.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/binary/byte_reader.rs new file mode 100644 index 0000000..26306ce --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/binary/byte_reader.rs @@ -0,0 +1,138 @@ +//! Utilities for reading little-endian values from byte slices. + +/// Reads a little-endian u32 from a byte slice. +/// +/// Returns `None` if the slice doesn't have enough bytes. +/// +/// # Examples +/// ``` +/// use steam_vdf_parser::binary::read_u32_le; +/// +/// let data = [0x01, 0x02, 0x03, 0x04]; +/// assert_eq!(read_u32_le(&data), Some(0x04030201)); +/// assert_eq!(read_u32_le(&[0x01, 0x02]), None); +/// ``` +#[inline] +pub fn read_u32_le(input: &[u8]) -> Option<u32> { + input.get(..4).and_then(|bytes| { + let arr: [u8; 4] = bytes.try_into().ok()?; + Some(u32::from_le_bytes(arr)) + }) +} + +/// Reads a little-endian u64 from a byte slice. +/// +/// Returns `None` if the slice doesn't have enough bytes. +/// +/// # Examples +/// ``` +/// use steam_vdf_parser::binary::read_u64_le; +/// +/// let data = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]; +/// assert_eq!(read_u64_le(&data), Some(0x0807060504030201)); +/// assert_eq!(read_u64_le(&[0x01, 0x02]), None); +/// ``` +#[inline] +pub fn read_u64_le(input: &[u8]) -> Option<u64> { + input.get(..8).and_then(|bytes| { + let arr: [u8; 8] = bytes.try_into().ok()?; + Some(u64::from_le_bytes(arr)) + }) +} + +/// Reads a little-endian u32 from a byte slice at a specific offset. +/// +/// Returns `None` if the slice doesn't have enough bytes from the offset. +/// +/// # Examples +/// ``` +/// use steam_vdf_parser::binary::read_u32_le_at; +/// +/// let data = [0xFF, 0xFF, 0x01, 0x02, 0x03, 0x04]; +/// assert_eq!(read_u32_le_at(&data, 2), Some(0x04030201)); +/// assert_eq!(read_u32_le_at(&data, 4), None); +/// ``` +#[inline] +pub fn read_u32_le_at(input: &[u8], offset: usize) -> Option<u32> { + input.get(offset..offset + 4).and_then(|bytes| { + let arr: [u8; 4] = bytes.try_into().ok()?; + Some(u32::from_le_bytes(arr)) + }) +} + +/// Reads a little-endian u64 from a byte slice at a specific offset. +/// +/// Returns `None` if the slice doesn't have enough bytes from the offset. +/// +/// # Examples +/// ``` +/// use steam_vdf_parser::binary::read_u64_le_at; +/// +/// let data = [0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]; +/// assert_eq!(read_u64_le_at(&data, 4), Some(0x0807060504030201)); +/// assert_eq!(read_u64_le_at(&data, 8), None); +/// ``` +#[inline] +pub fn read_u64_le_at(input: &[u8], offset: usize) -> Option<u64> { + input.get(offset..offset + 8).and_then(|bytes| { + let arr: [u8; 8] = bytes.try_into().ok()?; + Some(u64::from_le_bytes(arr)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_read_u32_le() { + let data = [0x01, 0x02, 0x03, 0x04]; + assert_eq!(read_u32_le(&data), Some(0x04030201)); + } + + #[test] + fn test_read_u32_le_short() { + assert_eq!(read_u32_le(&[0x01, 0x02]), None); + assert_eq!(read_u32_le(&[]), None); + } + + #[test] + fn test_read_u64_le() { + let data = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]; + assert_eq!(read_u64_le(&data), Some(0x0807060504030201)); + } + + #[test] + fn test_read_u64_le_short() { + assert_eq!(read_u64_le(&[0x01, 0x02, 0x03, 0x04]), None); + assert_eq!(read_u64_le(&[]), None); + } + + #[test] + fn test_read_u32_le_at() { + let data = [0xFF, 0xFF, 0x01, 0x02, 0x03, 0x04]; + assert_eq!(read_u32_le_at(&data, 0), Some(0x0201FFFF)); + assert_eq!(read_u32_le_at(&data, 2), Some(0x04030201)); + } + + #[test] + fn test_read_u32_le_at_out_of_bounds() { + let data = [0x01, 0x02, 0x03, 0x04]; + assert_eq!(read_u32_le_at(&data, 1), None); + assert_eq!(read_u32_le_at(&data, 4), None); + } + + #[test] + fn test_read_u64_le_at() { + let data = [0xFF; 12]; + assert_eq!(read_u64_le_at(&data, 0), Some(0xFFFFFFFFFFFFFFFF)); + assert_eq!(read_u64_le_at(&data, 4), Some(0xFFFFFFFFFFFFFFFF)); + } + + #[test] + fn test_read_u64_le_at_out_of_bounds() { + let data = [0x01; 8]; + assert_eq!(read_u64_le_at(&data, 1), None); + assert_eq!(read_u64_le_at(&data, 8), None); + } +} diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/binary/mod.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/binary/mod.rs new file mode 100644 index 0000000..ac73961 --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/binary/mod.rs @@ -0,0 +1,13 @@ +//! Binary VDF format parser. +//! +//! Supports Steam's binary VDF formats: +//! - shortcuts.vdf format (simple binary) +//! - appinfo.vdf format (with optional string table) + +mod byte_reader; +mod parser; +mod types; + +pub use byte_reader::{read_u32_le, read_u32_le_at, read_u64_le, read_u64_le_at}; +pub use parser::{parse, parse_appinfo, parse_packageinfo, parse_shortcuts}; +pub use types::{APPINFO_MAGIC_40, APPINFO_MAGIC_41, BinaryType}; diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/binary/parser.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/binary/parser.rs new file mode 100644 index 0000000..9ea1c0e --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/binary/parser.rs @@ -0,0 +1,1439 @@ +//! Binary VDF parser implementation. +//! +//! Supports Steam's binary VDF formats: +//! - shortcuts.vdf (simple binary format) +//! - appinfo.vdf (with optional string table) + +use alloc::borrow::Cow; +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::str; + +use crate::binary::byte_reader::{read_u32_le, read_u64_le}; +use crate::binary::types::{ + APPINFO_MAGIC_40, APPINFO_MAGIC_41, BinaryType, PACKAGEINFO_MAGIC_39, PACKAGEINFO_MAGIC_40, + PACKAGEINFO_MAGIC_BASE, +}; +use crate::error::{Error, Result, with_offset}; +use crate::value::{Obj, Value, Vdf}; + +// ===== Appinfo Header Constants ===== + +/// Size of the appinfo entry header (up to and including the size field). +const APPINFO_HEADER_SIZE: usize = 8; + +/// Size of the header after the size field (60 bytes). +const APPINFO_HEADER_AFTER_SIZE: usize = 60; + +/// Total size of the appinfo entry header. +const APPINFO_ENTRY_HEADER_SIZE: usize = APPINFO_HEADER_SIZE + APPINFO_HEADER_AFTER_SIZE; + +/// Offset where VDF data starts within an appinfo entry. +const APPINFO_VDF_DATA_OFFSET: usize = APPINFO_ENTRY_HEADER_SIZE; + +// ===== Helper Functions ===== + +/// Read a little-endian u32 from the start of a slice, returning an error if too small. +fn ensure_read_u32_le(input: &[u8]) -> Result<(&[u8], u32)> { + read_u32_le(input) + .map(|value| (&input[4..], value)) + .ok_or(Error::UnexpectedEndOfInput { + context: "reading u32", + offset: 0, + expected: 4, + actual: input.len(), + }) +} + +/// Parse configuration for binary VDF formats. +/// +/// Encapsulates the differences between shortcuts.vdf and appinfo.vdf formats. +#[derive(Clone, Copy, Debug, PartialEq, Default)] +struct ParseConfig<'input, 'table> { + /// Strategy for parsing keys + key_mode: KeyMode<'input, 'table>, +} + +/// Key parsing strategy for binary VDF formats. +#[derive(Clone, Copy, Debug, PartialEq, Default)] +enum KeyMode<'input, 'table> { + /// Parse keys as null-terminated UTF-8 strings (v40, shortcuts) + #[default] + NullTerminated, + /// Parse keys as u32 indices into string table (v41) + StringTableIndex { + string_table: &'table StringTable<'input>, + }, +} + +/// String table for v41 appinfo format. +/// +/// Encapsulates pre-extracted strings from the string table section, +/// enabling O(1) lookups by index. +#[derive(Clone, Debug, PartialEq)] +struct StringTable<'a> { + strings: Vec<&'a str>, +} + +impl<'a> StringTable<'a> { + /// Get a string by index. + fn get(&self, index: usize) -> Result<&'a str> { + self.strings + .get(index) + .copied() + .ok_or(Error::InvalidStringIndex { + index, + max: self.strings.len(), + }) + } +} + +impl<'a> KeyMode<'a, '_> { + /// Parse a key from input according to this mode. + fn parse_key(&self, input: &'a [u8]) -> Result<(&'a [u8], Cow<'a, str>)> { + match self { + KeyMode::NullTerminated => { + let (rest, s) = parse_null_terminated_string_borrowed(input)?; + Ok((rest, Cow::Borrowed(s))) + } + KeyMode::StringTableIndex { string_table } => { + let (rest, index) = ensure_read_u32_le(input)?; + let s = string_table.get(index as usize)?; + Ok((rest, Cow::Borrowed(s))) + } + } + } +} + +/// Parse binary VDF data (autodetects format). +/// +/// Attempts to parse as appinfo.vdf first, then falls back to shortcuts.vdf format. +/// For shortcuts format, returns zero-copy data borrowed from input. +/// For appinfo format, returns mixed data: root key and app ID keys are owned, +/// but actual parsed values (including string table entries) are borrowed. +/// For packageinfo format, returns mixed data similar to appinfo. +pub fn parse(input: &[u8]) -> Result<Vdf<'_>> { + // Check if this looks like appinfo or packageinfo format (starts with magic) + if let Some(magic) = read_u32_le(input) { + if magic == APPINFO_MAGIC_40 || magic == APPINFO_MAGIC_41 { + return parse_appinfo(input); + } + if magic == PACKAGEINFO_MAGIC_39 || magic == PACKAGEINFO_MAGIC_40 { + return parse_packageinfo(input); + } + } + + // Otherwise, parse as shortcuts format (zero-copy) + parse_shortcuts(input) +} + +/// Parse shortcuts.vdf format binary data. +/// +/// This is the simpler binary format used by Steam for shortcuts and other data. +/// +/// This function returns zero-copy data - strings are borrowed from the input buffer. +/// +/// Format: +/// - Each entry starts with a type byte +/// - Type 0x00: Object start (key is the object name) +/// - Type 0x01: String value +/// - Type 0x02: Int32 value +/// - Type 0x08: Object end +/// +/// All strings are null-terminated. +pub fn parse_shortcuts(input: &[u8]) -> Result<Vdf<'_>> { + let config = ParseConfig::default(); + let (_rest, obj) = parse_object(input, &config)?; + + Ok(Vdf::new("root", Value::Obj(obj))) +} + +/// Parse appinfo.vdf format binary data. +/// +/// This function returns zero-copy data where possible - strings are borrowed from +/// the input buffer (including string table entries in v41 format). +/// +/// Format: +/// - 4 bytes: Magic number (0x07564428 or 0x07564429) +/// - 4 bytes: Universe +/// - If magic == 0x07564429: 8 bytes: String table offset +/// - Apps continue until EOF (or string table for v41) +/// - For each app: +/// - 4 bytes: App ID +/// - 4 bytes: Size (remaining data size for this entry) +/// - 4 bytes: InfoState +/// - 4 bytes: LastUpdated (Unix timestamp) +/// - 8 bytes: AccessToken +/// - 20 bytes: SHA1 of text data +/// - 4 bytes: ChangeNumber +/// - 20 bytes: SHA1 of binary data +/// - Then the VDF data for the app (starts with 0x00) +/// - String table (if magic == 0x07564429, at string_table_offset) +/// +/// App entry header is `APPINFO_ENTRY_HEADER_SIZE` (68) bytes. +pub fn parse_appinfo(input: &[u8]) -> Result<Vdf<'_>> { + if input.len() < 16 { + return Err(Error::UnexpectedEndOfInput { + context: "reading appinfo header", + offset: input.len(), + expected: 16, + actual: input.len(), + }); + } + + let Some(magic) = read_u32_le(input) else { + return Err(Error::UnexpectedEndOfInput { + context: "reading magic number", + offset: 0, + expected: 4, + actual: input.len(), + }); + }; + let Some(universe) = read_u32_le(&input[4..]) else { + return Err(Error::UnexpectedEndOfInput { + context: "reading universe", + offset: 4, + expected: 4, + actual: input.len() - 4, + }); + }; + + let (string_table_offset, mut rest) = match magic { + APPINFO_MAGIC_40 => (None, &input[8..]), + APPINFO_MAGIC_41 => { + let Some(offset) = read_u64_le(&input[8..]) else { + return Err(Error::UnexpectedEndOfInput { + context: "reading string table offset", + offset: 8, + expected: 8, + actual: input.len() - 8, + }); + }; + (Some(offset as usize), &input[16..]) + } + _ => { + return Err(Error::InvalidMagic { + found: magic, + expected: &[APPINFO_MAGIC_40, APPINFO_MAGIC_41], + }); + } + }; + + // Parse the string table if present + let string_table = if let Some(offset) = string_table_offset { + if offset >= input.len() { + return Err(Error::UnexpectedEndOfInput { + context: "reading string table", + offset, + expected: 4, + actual: input.len() - offset, + }); + } + Some(parse_string_table(&input[offset..]).map_err(with_offset(offset))?) + } else { + None + }; + + let mut obj = Obj::new(); + + // Calculate where apps end (at string table for v41, or EOF for v40) + let apps_end_offset = string_table_offset.unwrap_or(input.len()); + + // Use v41 format (string table) if string_table_offset is Some + let config = ParseConfig { + key_mode: if let Some(string_table) = &string_table { + KeyMode::StringTableIndex { string_table } + } else { + KeyMode::NullTerminated + }, + }; + + loop { + // Check if we've reached the end of apps section + let current_offset = input.len() - rest.len(); + if current_offset >= apps_end_offset { + break; + } + + // Not enough data for an app entry header. + if rest.len() < APPINFO_ENTRY_HEADER_SIZE { + return Err(Error::UnexpectedEndOfInput { + context: "reading app entry header", + offset: current_offset, + expected: APPINFO_ENTRY_HEADER_SIZE, + actual: rest.len(), + }); + } + + // App ID (offset 0) + let Some(app_id) = read_u32_le(rest) else { + return Err(Error::UnexpectedEndOfInput { + context: "reading app id", + offset: current_offset, + expected: 4, + actual: rest.len(), + }); + }; + if app_id == 0 { + break; + } + + // Size (offset 4) - includes everything AFTER this field (APPINFO_HEADER_AFTER_SIZE bytes + VDF data) + let Some(size) = read_u32_le(&rest[4..]) else { + return Err(Error::UnexpectedEndOfInput { + context: "reading entry size", + offset: current_offset + 4, + expected: 4, + actual: rest.len() - 4, + }); + }; + let size = size as usize; + + // VDF data starts after the header + let vdf_size = size - APPINFO_HEADER_AFTER_SIZE; + let vdf_end = APPINFO_VDF_DATA_OFFSET + vdf_size; + + if vdf_end > rest.len() { + return Err(Error::UnexpectedEndOfInput { + context: "reading VDF data", + offset: current_offset + vdf_end, + expected: vdf_end, + actual: rest.len(), + }); + } + + let vdf_data = &rest[APPINFO_VDF_DATA_OFFSET..vdf_end]; + let vdf_offset = current_offset + APPINFO_VDF_DATA_OFFSET; + + let (_vdf_rest, app_obj) = + parse_object(vdf_data, &config).map_err(with_offset(vdf_offset))?; + + // Insert with app ID as key + obj.insert(Cow::Owned(app_id.to_string()), Value::Obj(app_obj)); + rest = &rest[vdf_end..]; + } + + Ok(Vdf::new( + format!("appinfo_universe_{}", universe), + Value::Obj(obj), + )) +} + +/// Parses an object from binary VDF data. +/// +/// This function implements a state machine that: +/// 1. Reads a type byte to determine the entry type +/// 2. Parses a key (format depends on `config.key_mode`) +/// 3. Parses the value based on the type byte +/// 4. Inserts the key-value pair into the object +/// 5. Returns on `ObjectEnd` (0x08) marker +/// +/// # Parameters +/// - `input`: Binary data to parse +/// - `config`: Parse configuration including string table reference +/// +/// # Returns +/// A tuple of remaining input and the parsed object. +fn parse_object<'a>(input: &'a [u8], config: &ParseConfig<'a, '_>) -> Result<(&'a [u8], Obj<'a>)> { + let mut obj = Obj::new(); + let mut rest = input; + + loop { + match rest { + [] => { + // At root level, EOF is acceptable - file may end without trailing 0x08 + break Ok((rest, obj)); + } + [type_byte, remainder @ ..] => { + let type_byte = *type_byte; + let typ = BinaryType::from_byte(type_byte); + let offset = input.len() - remainder.len(); + rest = remainder; + + match typ { + Some(BinaryType::ObjectEnd) => { + // Consume the end marker and return + return Ok((rest, obj)); + } + Some(BinaryType::None) => { + // Map entry: 0x00 [key] { ... entries ... } + let key_offset = input.len() - rest.len(); + let (new_rest, key) = config + .key_mode + .parse_key(rest) + .map_err(with_offset(key_offset))?; + let (new_rest, nested_obj) = parse_object(new_rest, config)?; + obj.insert(key, Value::Obj(nested_obj)); + rest = new_rest; + } + Some(BinaryType::String) => { + // String entry: 0x01 [key] [value] + // VALUE is ALWAYS inline null-terminated string (never from string table!) + let key_offset = input.len() - rest.len(); + let (new_rest, key) = config + .key_mode + .parse_key(rest) + .map_err(with_offset(key_offset))?; + let value_offset = input.len() - new_rest.len(); + let (new_rest, value) = parse_null_terminated_string_borrowed(new_rest) + .map_err(with_offset(value_offset))?; + obj.insert(key, Value::Str(Cow::Borrowed(value))); + rest = new_rest; + } + Some(BinaryType::Int32) => { + let key_offset = input.len() - rest.len(); + let (new_rest, key) = config + .key_mode + .parse_key(rest) + .map_err(with_offset(key_offset))?; + let value_offset = input.len() - new_rest.len(); + let (new_rest, value) = + parse_value_int32(new_rest).map_err(with_offset(value_offset))?; + obj.insert(key, value); + rest = new_rest; + } + Some(BinaryType::UInt64) => { + let key_offset = input.len() - rest.len(); + let (new_rest, key) = config + .key_mode + .parse_key(rest) + .map_err(with_offset(key_offset))?; + let value_offset = input.len() - new_rest.len(); + let (new_rest, value) = + parse_value_uint64(new_rest).map_err(with_offset(value_offset))?; + obj.insert(key, value); + rest = new_rest; + } + Some(BinaryType::Float) => { + let key_offset = input.len() - rest.len(); + let (new_rest, key) = config + .key_mode + .parse_key(rest) + .map_err(with_offset(key_offset))?; + let value_offset = input.len() - new_rest.len(); + let (new_rest, value) = + parse_value_float(new_rest).map_err(with_offset(value_offset))?; + obj.insert(key, value); + rest = new_rest; + } + Some(BinaryType::Ptr) => { + let key_offset = input.len() - rest.len(); + let (new_rest, key) = config + .key_mode + .parse_key(rest) + .map_err(with_offset(key_offset))?; + let value_offset = input.len() - new_rest.len(); + let (new_rest, value) = + parse_value_ptr(new_rest).map_err(with_offset(value_offset))?; + obj.insert(key, value); + rest = new_rest; + } + Some(BinaryType::WString) => { + let key_offset = input.len() - rest.len(); + let (new_rest, key) = config + .key_mode + .parse_key(rest) + .map_err(with_offset(key_offset))?; + let value_offset = input.len() - new_rest.len(); + let (new_rest, value) = + parse_value_wstring(new_rest).map_err(with_offset(value_offset))?; + obj.insert(key, value); + rest = new_rest; + } + Some(BinaryType::Color) => { + let key_offset = input.len() - rest.len(); + let (new_rest, key) = config + .key_mode + .parse_key(rest) + .map_err(with_offset(key_offset))?; + let value_offset = input.len() - new_rest.len(); + let (new_rest, value) = + parse_value_color(new_rest).map_err(with_offset(value_offset))?; + obj.insert(key, value); + rest = new_rest; + } + None => { + // Unknown type byte + return Err(Error::UnknownType { type_byte, offset }); + } + } + } + } + } +} + +// ===== Value Parser Functions ===== + +/// Parse an Int32 value (4 bytes, little-endian). +fn parse_value_int32<'a>(input: &'a [u8]) -> Result<(&'a [u8], Value<'a>)> { + let arr = <[u8; 4]>::try_from(input.get(..4).ok_or(Error::UnexpectedEndOfInput { + context: "reading int32", + offset: 0, + expected: 4, + actual: input.len(), + })?) + .map_err(|_| Error::UnexpectedEndOfInput { + context: "reading int32", + offset: 0, + expected: 4, + actual: input.len(), + })?; + let value = i32::from_le_bytes(arr); + Ok((&input[4..], Value::I32(value))) +} + +/// Parse a UInt64 value (8 bytes, little-endian). +fn parse_value_uint64<'a>(input: &'a [u8]) -> Result<(&'a [u8], Value<'a>)> { + let arr = <[u8; 8]>::try_from(input.get(..8).ok_or(Error::UnexpectedEndOfInput { + context: "reading uint64", + offset: 0, + expected: 8, + actual: input.len(), + })?) + .map_err(|_| Error::UnexpectedEndOfInput { + context: "reading uint64", + offset: 0, + expected: 8, + actual: input.len(), + })?; + let value = u64::from_le_bytes(arr); + Ok((&input[8..], Value::U64(value))) +} + +/// Parse a Float value (4 bytes, little-endian). +fn parse_value_float<'a>(input: &'a [u8]) -> Result<(&'a [u8], Value<'a>)> { + let arr = <[u8; 4]>::try_from(input.get(..4).ok_or(Error::UnexpectedEndOfInput { + context: "reading float", + offset: 0, + expected: 4, + actual: input.len(), + })?) + .map_err(|_| Error::UnexpectedEndOfInput { + context: "reading float", + offset: 0, + expected: 4, + actual: input.len(), + })?; + let value = f32::from_le_bytes(arr); + Ok((&input[4..], Value::Float(value))) +} + +/// Parse a Pointer value (4 bytes, little-endian). +fn parse_value_ptr<'a>(input: &'a [u8]) -> Result<(&'a [u8], Value<'a>)> { + let (rest, value) = ensure_read_u32_le(input)?; + Ok((rest, Value::Pointer(value))) +} + +/// Parse a WideString value (UTF-16LE, null-terminated). +fn parse_value_wstring<'a>(input: &'a [u8]) -> Result<(&'a [u8], Value<'a>)> { + let (rest, string) = parse_null_terminated_wstring(input)?; + Ok((rest, Value::Str(Cow::Owned(string)))) +} + +/// Parse a Color value (4 bytes RGBA). +fn parse_value_color<'a>(input: &'a [u8]) -> Result<(&'a [u8], Value<'a>)> { + let arr = <[u8; 4]>::try_from(input.get(..4).ok_or(Error::UnexpectedEndOfInput { + context: "reading color", + offset: 0, + expected: 4, + actual: input.len(), + })?) + .map_err(|_| Error::UnexpectedEndOfInput { + context: "reading color", + offset: 0, + expected: 4, + actual: input.len(), + })?; + Ok((&input[4..], Value::Color(arr))) +} + +// ===== String Parsing Functions ===== + +/// Parse a null-terminated string (UTF-8), returning a borrowed slice. +/// +/// This is the zero-copy version that borrows from the input when possible. +fn parse_null_terminated_string_borrowed(input: &[u8]) -> Result<(&[u8], &str)> { + let null_pos = input + .iter() + .position(|&b| b == 0) + .ok_or(Error::UnexpectedEndOfInput { + context: "reading null-terminated string", + offset: 0, + expected: 1, + actual: input.len(), + })?; + + let bytes = &input[..null_pos]; + // Some apps have binary blobs where strings are expected (SHA1 hashes + // stored as "strings" in common blocks). Treat invalid-UTF-8 as empty + // so the parser keeps going rather than failing the whole file. + let string = core::str::from_utf8(bytes).unwrap_or(""); + + Ok((&input[null_pos + 1..], string)) +} + +/// Parse a null-terminated wide string (UTF-16LE). +/// +/// WideString is terminated by two zero bytes (0x00 0x00). +/// Note: This allocates due to UTF-16 to UTF-8 conversion. +fn parse_null_terminated_wstring(input: &[u8]) -> Result<(&[u8], String)> { + // Find the double-null terminator + let mut i = 0; + while i + 1 < input.len() { + if input[i] == 0 && input[i + 1] == 0 { + break; + } + i += 2; + } + + if i + 1 >= input.len() { + return Err(Error::UnexpectedEndOfInput { + context: "reading null-terminated wide string", + offset: i, + expected: 2, + actual: input.len().saturating_sub(i), + }); + } + + // Convert UTF-16LE to u16 code units + let utf16_units = input[..i] + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])); + + // Decode UTF-16 to char and then to String + let string: String = char::decode_utf16(utf16_units) + .enumerate() + .map(|(pos, r)| { + r.map_err(|_| Error::InvalidUtf16 { + offset: pos * 2, + position: pos, + }) + }) + .collect::<core::result::Result<_, _>>()?; + + Ok((&input[i + 2..], string)) +} + +/// Parse the string table section (v41 format). +/// +/// Returns a `StringTable` containing pre-extracted strings for O(1) lookups. +/// +/// Format: +/// - 4 bytes: string_count (little-endian u32) +/// - Then string_count null-terminated UTF-8 strings +fn parse_string_table(input: &[u8]) -> Result<StringTable<'_>> { + let (mut rest, string_count) = ensure_read_u32_le(input)?; + let string_count = string_count as usize; + + let mut strings = Vec::with_capacity(string_count); + + // Extract each null-terminated string + for _ in 0..string_count { + if rest.is_empty() { + return Err(Error::UnexpectedEndOfInput { + context: "reading string table entry", + offset: input.len() - rest.len(), + expected: 1, + actual: 0, + }); + } + let (new_rest, string) = parse_null_terminated_string_borrowed(rest)?; + strings.push(string); + rest = new_rest; + } + + Ok(StringTable { strings }) +} + +/// Header size for packageinfo entries (package_id + hash + change_number + token). +const PACKAGEINFO_ENTRY_HEADER_SIZE_V39: usize = 4 + 20 + 4; // package_id + hash + change_number +const PACKAGEINFO_ENTRY_HEADER_SIZE_V40: usize = 4 + 20 + 4 + 8; // + token + +/// Parse packageinfo.vdf format binary data. +/// +/// This function returns zero-copy data where possible - strings are borrowed from +/// the input buffer. +/// +/// Format: +/// - 4 bytes: Magic number + version (0x06565527 for v39, 0x06565528 for v40) +/// - Upper 3 bytes: 0x065655 (magic) +/// - Lower 1 byte: version (27 = 39, 28 = 40) +/// - 4 bytes: Universe +/// - Repeated package entries until package_id == 0xFFFFFFFF: +/// - 4 bytes: Package ID (uint32) +/// - 20 bytes: SHA-1 hash +/// - 4 bytes: Change number (uint32) +/// - 8 bytes: PICS token (uint64, only in v40+) +/// - Binary VDF blob (KeyValues1 binary) with package metadata +pub fn parse_packageinfo(input: &[u8]) -> Result<Vdf<'_>> { + if input.len() < 8 { + return Err(Error::UnexpectedEndOfInput { + context: "reading packageinfo header", + offset: input.len(), + expected: 8, + actual: input.len(), + }); + } + + let Some(magic) = read_u32_le(input) else { + return Err(Error::UnexpectedEndOfInput { + context: "reading magic number", + offset: 0, + expected: 4, + actual: input.len(), + }); + }; + + // Extract version from lower byte and magic from upper 3 bytes + let version = magic & 0xFF; + let magic_base = magic >> 8; + + if magic_base != PACKAGEINFO_MAGIC_BASE { + return Err(Error::InvalidMagic { + found: magic, + expected: &[PACKAGEINFO_MAGIC_39, PACKAGEINFO_MAGIC_40], + }); + } + + if version != 39 && version != 40 { + return Err(Error::InvalidMagic { + found: magic, + expected: &[PACKAGEINFO_MAGIC_39, PACKAGEINFO_MAGIC_40], + }); + } + + let Some(universe) = read_u32_le(&input[4..]) else { + return Err(Error::UnexpectedEndOfInput { + context: "reading universe", + offset: 4, + expected: 4, + actual: input.len() - 4, + }); + }; + + let has_token = version >= 40; + let header_size = if has_token { + PACKAGEINFO_ENTRY_HEADER_SIZE_V40 + } else { + PACKAGEINFO_ENTRY_HEADER_SIZE_V39 + }; + + let mut rest = &input[8..]; + let mut obj = Obj::new(); + + loop { + // Check if we have at least 4 bytes for the package ID + if rest.len() < 4 { + // At EOF or termination marker, exit gracefully + break; + } + + // Read package ID + let Some(package_id) = read_u32_le(rest) else { + break; + }; + + // Check for termination marker + if package_id == 0xFFFFFFFF { + break; + } + + // Now ensure we have enough data for the full header + if rest.len() < header_size { + return Err(Error::UnexpectedEndOfInput { + context: "reading package entry header", + offset: input.len() - rest.len(), + expected: header_size, + actual: rest.len(), + }); + } + + // Skip hash (20 bytes), read change number + let hash_offset = 4; + let change_number_offset = hash_offset + 20; + + let Some(change_number) = read_u32_le(&rest[change_number_offset..]) else { + return Err(Error::UnexpectedEndOfInput { + context: "reading change number", + offset: input.len() - rest.len() + change_number_offset, + expected: 4, + actual: rest.len() - change_number_offset, + }); + }; + + // Skip token if present (8 bytes after change_number) + let vdf_data_offset = if has_token { + change_number_offset + 4 + 8 + } else { + change_number_offset + 4 + }; + + // Parse the VDF data for this package + let vdf_data = &rest[vdf_data_offset..]; + + let config = ParseConfig::default(); // Uses null-terminated keys like shortcuts + + let (_vdf_rest, package_obj) = + parse_object(vdf_data, &config).map_err(with_offset(input.len() - vdf_data.len()))?; + + // Create metadata object for this package + let mut package_with_meta = Obj::new(); + + // Add metadata fields + package_with_meta.insert(Cow::Borrowed("packageid"), Value::I32(package_id as i32)); + package_with_meta.insert( + Cow::Borrowed("change_number"), + Value::U64(change_number as u64), + ); + package_with_meta.insert( + Cow::Borrowed("sha1"), + Value::Str(Cow::Owned(hex::encode( + &rest[hash_offset..hash_offset + 20], + ))), + ); + + // Merge the parsed VDF data + for (key, value) in package_obj.iter() { + package_with_meta.insert(key.clone(), value.clone()); + } + + // Insert with package ID as key + obj.insert( + Cow::Owned(package_id.to_string()), + Value::Obj(package_with_meta), + ); + + // Find the end of this VDF object to move to the next entry + // _vdf_rest from the first parse_object call above tells us where VDF data ended + let vdf_end = vdf_data.len() - _vdf_rest.len(); + rest = &rest[vdf_data_offset + vdf_end..]; + } + + Ok(Vdf::new( + format!("packageinfo_universe_{}", universe), + Value::Obj(obj), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_object() { + // Simple binary VDF: "test" { "key" "value" } + let data: &[u8] = &[ + 0x00, // Object start + b't', b'e', b's', b't', 0x00, // Key "test" + 0x01, // String type + b'k', b'e', b'y', 0x00, // Key "key" + b'v', b'a', b'l', b'u', b'e', 0x00, // Value "value" + 0x08, // Object end + ]; + + let result = parse_shortcuts(data); + assert!(result.is_ok(), "Failed to parse: {:?}", result.err()); + + let vdf = result.unwrap(); + assert_eq!(vdf.key(), "root"); + + let obj = vdf.as_obj().unwrap(); + let test_obj = obj.get("test").and_then(|v| v.as_obj()); + assert!(test_obj.is_some()); + + let test_obj = test_obj.unwrap(); + let value = test_obj.get("key").and_then(|v| v.as_str()); + assert_eq!(value, Some("value")); + } + + #[test] + fn test_parse_nested_objects() { + // Nested objects: "outer" { "inner" { "key" "value" } } + let data: &[u8] = &[ + 0x00, // Object start + b'o', b'u', b't', b'e', b'r', 0x00, // Key "outer" + 0x00, // Nested object start + b'i', b'n', b'n', b'e', b'r', 0x00, // Key "inner" + 0x01, // String type + b'k', b'e', b'y', 0x00, // Key "key" + b'v', b'a', b'l', b'u', b'e', 0x00, // Value "value" + 0x08, // End inner object + 0x08, // End outer object + ]; + + let result = parse_shortcuts(data); + assert!(result.is_ok()); + + let vdf = result.unwrap(); + let obj = vdf.as_obj().unwrap(); + let outer = obj.get("outer").and_then(|v| v.as_obj()).unwrap(); + let inner = outer.get("inner").and_then(|v| v.as_obj()).unwrap(); + let value = inner.get("key").and_then(|v| v.as_str()); + assert_eq!(value, Some("value")); + } + + #[test] + fn test_parse_int32_value() { + // Int32 value: "root" { "number" "42" } + let data: &[u8] = &[ + 0x00, // Object start + b'r', b'o', b'o', b't', 0x00, // Key "root" + 0x02, // Int32 type + b'n', b'u', b'm', b'b', b'e', b'r', 0x00, // Key "number" + 42, 0, 0, 0, // Value 42 (little-endian) + 0x08, // Object end + ]; + + let result = parse_shortcuts(data); + assert!(result.is_ok()); + + let vdf = result.unwrap(); + let obj = vdf.as_obj().unwrap(); + let root = obj.get("root").and_then(|v| v.as_obj()).unwrap(); + let value = root.get("number").and_then(|v| v.as_i32()); + assert_eq!(value, Some(42)); + } + + #[test] + fn test_parse_uint64_value() { + // UInt64 value + let data: &[u8] = &[ + 0x00, // Object start + b'r', b'o', b'o', b't', 0x00, // Key "root" + 0x07, // UInt64 type + b'n', b'u', b'm', b'b', b'e', b'r', 0x00, // Key "number" + 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, // Value u32::MAX as u64 + 0x08, // Object end + ]; + + let result = parse_shortcuts(data); + assert!(result.is_ok()); + + let vdf = result.unwrap(); + let obj = vdf.as_obj().unwrap(); + let root = obj.get("root").and_then(|v| v.as_obj()).unwrap(); + let value = root.get("number").and_then(|v| v.as_u64()); + assert_eq!(value, Some(4294967295)); + } + + #[test] + fn test_parse_float_value() { + // Float value + let data: &[u8] = &[ + 0x00, // Object start + b'r', b'o', b'o', b't', 0x00, // Key "root" + 0x03, // Float type + b'v', b'a', b'l', 0x00, // Key "val" + 0x00, 0x00, 0x80, 0x3F, // Value 1.0 (little-endian) + 0x08, // Object end + ]; + + let result = parse_shortcuts(data); + assert!(result.is_ok()); + + let vdf = result.unwrap(); + let obj = vdf.as_obj().unwrap(); + let root = obj.get("root").and_then(|v| v.as_obj()).unwrap(); + let value = root.get("val").and_then(|v| v.as_float()); + assert_eq!(value, Some(1.0)); + } + + #[test] + fn test_parse_ptr_value() { + // Pointer value + let data: &[u8] = &[ + 0x00, // Object start + b'r', b'o', b'o', b't', 0x00, // Key "root" + 0x04, // Ptr type + b'p', b't', b'r', 0x00, // Key "ptr" + 0xAB, 0xCD, 0xEF, 0x12, // Value 0x12EFCDAB + 0x08, // Object end + ]; + + let result = parse_shortcuts(data); + assert!(result.is_ok()); + + let vdf = result.unwrap(); + let obj = vdf.as_obj().unwrap(); + let root = obj.get("root").and_then(|v| v.as_obj()).unwrap(); + let value = root.get("ptr").and_then(|v| v.as_pointer()); + assert_eq!(value, Some(0x12efcdab)); + } + + #[test] + fn test_parse_color_value() { + // Color value: RGBA (255, 0, 0, 255) = "25500255" + let data: &[u8] = &[ + 0x00, // Object start + b'r', b'o', b'o', b't', 0x00, // Key "root" + 0x06, // Color type + b'c', b'o', b'l', 0x00, // Key "col" + 0xFF, 0x00, 0x00, 0xFF, // RGBA: red, opaque + 0x08, // Object end + ]; + + let result = parse_shortcuts(data); + assert!(result.is_ok()); + + let vdf = result.unwrap(); + let obj = vdf.as_obj().unwrap(); + let root = obj.get("root").and_then(|v| v.as_obj()).unwrap(); + let value = root.get("col").and_then(|v| v.as_color()); + assert_eq!(value, Some([255, 0, 0, 255])); + } + + // ===== Error Path Tests ===== + + #[test] + fn test_parse_unknown_type_byte() { + let data: &[u8] = &[ + 0x00, b't', b'e', b's', b't', 0x00, 0xFF, // Invalid type byte + b'k', b'e', b'y', 0x00, + ]; + assert!(matches!( + parse_shortcuts(data), + Err(Error::UnknownType { + type_byte: 0xFF, + .. + }) + )); + } + + #[test] + fn test_parse_truncated_object_start() { + let data: &[u8] = &[0x00]; // Incomplete object start + assert!(matches!( + parse_shortcuts(data), + Err(Error::UnexpectedEndOfInput { .. }) + )); + } + + #[test] + fn test_parse_truncated_string_value() { + let data: &[u8] = &[ + 0x00, b't', b'e', b's', b't', 0x00, 0x01, // String type + b'k', b'e', b'y', 0x00, + // Missing null terminator + ]; + assert!(matches!( + parse_shortcuts(data), + Err(Error::UnexpectedEndOfInput { .. }) + )); + } + + #[test] + fn test_parse_invalid_utf8_string() { + let data: &[u8] = &[ + 0x00, b't', b'e', b's', b't', 0x00, 0x01, // String type + b'k', b'e', b'y', 0x00, 0xFF, 0xFF, 0x00, // Invalid UTF-8 followed by null + ]; + assert!(matches!( + parse_shortcuts(data), + Err(Error::InvalidUtf8 { .. }) + )); + } + + #[test] + fn test_parse_truncated_int32_value() { + let data: &[u8] = &[ + 0x00, b't', b'e', b's', b't', 0x00, 0x02, // Int32 type + b'k', b'e', b'y', 0x00, 0x01, 0x02, // Only 2 bytes instead of 4 + ]; + assert!(matches!( + parse_shortcuts(data), + Err(Error::UnexpectedEndOfInput { .. }) + )); + } + + #[test] + fn test_parse_truncated_uint64_value() { + let data: &[u8] = &[ + 0x00, b't', b'e', b's', b't', 0x00, 0x07, // UInt64 type + b'k', b'e', b'y', 0x00, 0x01, 0x02, 0x03, 0x04, // Only 4 bytes instead of 8 + ]; + assert!(matches!( + parse_shortcuts(data), + Err(Error::UnexpectedEndOfInput { .. }) + )); + } + + #[test] + fn test_parse_truncated_float_value() { + let data: &[u8] = &[ + 0x00, b't', b'e', b's', b't', 0x00, 0x03, // Float type + b'k', b'e', b'y', 0x00, 0x01, 0x02, // Only 2 bytes instead of 4 + ]; + assert!(matches!( + parse_shortcuts(data), + Err(Error::UnexpectedEndOfInput { .. }) + )); + } + + #[test] + fn test_parse_truncated_color_value() { + let data: &[u8] = &[ + 0x00, b't', b'e', b's', b't', 0x00, 0x06, // Color type + b'k', b'e', b'y', 0x00, 0xFF, 0x00, // Only 2 bytes instead of 4 + ]; + assert!(matches!( + parse_shortcuts(data), + Err(Error::UnexpectedEndOfInput { .. }) + )); + } + + #[test] + fn test_parse_wstring_unpaired_surrogate() { + // WideString with unpaired surrogate - Rust's decode_utf16 replaces with + // replacement character rather than erroring, so this should parse successfully + let data: &[u8] = &[ + 0x00, b't', b'e', b's', b't', 0x00, 0x05, // WideString type + b'k', b'e', b'y', 0x00, 0xD8, 0x00, 0x00, + 0x00, // Unpaired surrogate (UTF-16) - gets replaced + ]; + assert!(parse_shortcuts(data).is_ok()); + } + + #[test] + fn test_parse_appinfo_invalid_magic() { + let data: &[u8] = &[ + 0xDE, 0xAD, 0xBE, 0xEF, // Invalid magic + 0x00, 0x00, 0x00, 0x00, // universe + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // padding to meet minimum size + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]; + assert!(matches!( + parse_appinfo(data), + Err(Error::InvalidMagic { .. }) + )); + } + + #[test] + fn test_parse_appinfo_truncated_header() { + let data: &[u8] = &[ + 0x28, 0x44, 0x56, 0x07, // APPINFO_MAGIC_41 first byte + ]; + assert!(matches!( + parse_appinfo(data), + Err(Error::UnexpectedEndOfInput { .. }) + )); + } + + #[test] + fn test_parse_appinfo_v41_invalid_string_table_offset() { + // v41 with string table offset beyond file length + let data: &[u8] = &[ + 0x28, 0x44, 0x56, 0x07, // APPINFO_MAGIC_41 + 0x00, 0x00, 0x00, 0x00, // universe + 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, // string table offset (255, beyond EOF) + ]; + assert!(matches!( + parse_appinfo(data), + Err(Error::UnexpectedEndOfInput { .. }) + )); + } + + #[test] + fn test_parse_packageinfo_invalid_magic() { + let data: &[u8] = &[0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x00, 0x00, 0x00]; + assert!(matches!( + parse_packageinfo(data), + Err(Error::InvalidMagic { .. }) + )); + } + + #[test] + fn test_parse_packageinfo_truncated_header() { + let data: &[u8] = &[0x27]; // Partial magic + assert!(matches!( + parse_packageinfo(data), + Err(Error::UnexpectedEndOfInput { .. }) + )); + } + + #[test] + fn test_parse_appinfo_with_terminator() { + // v40 format with immediate app_id terminator (no apps) + // Need 68 bytes minimum (APPINFO_ENTRY_HEADER_SIZE) to pass the size check + let data: &[u8] = &[ + 0x28, 0x44, 0x56, 0x07, // APPINFO_MAGIC_40 + 0x00, 0x00, 0x00, 0x00, // universe + 0x00, 0x00, 0x00, 0x00, // app_id = 0 (terminator) + // Padding to meet APPINFO_ENTRY_HEADER_SIZE (68 bytes total) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + ]; + let result = parse_appinfo(data); + if let Err(e) = &result { + panic!("parse_appinfo failed with: {:?}", e); + } + assert!( + result.is_ok(), + "Appinfo with terminator should parse successfully" + ); + let vdf = result.unwrap(); + let obj = vdf.as_obj().unwrap(); + assert_eq!(obj.len(), 0, "Should have no apps"); + } + + #[test] + fn test_parse_autodetect_fallback_to_shortcuts() { + // Data that doesn't look like appinfo should be parsed as shortcuts + let data: &[u8] = &[ + 0x00, // Object start (not appinfo magic) + b't', b'e', b's', b't', 0x00, 0x01, // String type + b'k', b'e', b'y', 0x00, b'v', b'a', b'l', b'u', b'e', 0x00, 0x08, // Object end + ]; + let result = parse(data); + assert!(result.is_ok()); + } + + // ===== packageinfo tests ===== + + #[test] + fn test_parse_packageinfo_v39_invalid_magic_base() { + // Correct version (39 = 0x27) but wrong magic base + let data: &[u8] = &[ + 0x27, 0xBE, 0xBA, 0xFE, // Wrong magic base (0xFEBAFE instead of 0x065655) + 0x00, 0x00, 0x00, 0x00, // universe + ]; + assert!(matches!( + parse_packageinfo(data), + Err(Error::InvalidMagic { .. }) + )); + } + + #[test] + fn test_parse_packageinfo_invalid_version() { + // Correct magic base but wrong version (38 instead of 39/40) + // 0x06565526 = version 38 + let data: &[u8] = &[ + 0x26, 0x55, 0x56, 0x06, // magic with version 38 + 0x00, 0x00, 0x00, 0x00, // universe + ]; + assert!(matches!( + parse_packageinfo(data), + Err(Error::InvalidMagic { .. }) + )); + } + + #[test] + fn test_parse_packageinfo_v39_truncated_universe() { + // v39 magic but missing universe bytes + let data: &[u8] = &[ + 0x27, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_39 + 0x00, 0x00, // incomplete universe (only 2 bytes) + ]; + assert!(matches!( + parse_packageinfo(data), + Err(Error::UnexpectedEndOfInput { .. }) + )); + } + + #[test] + fn test_parse_packageinfo_v39_with_terminator() { + // v39 format with immediate termination marker (no packages) + let data: &[u8] = &[ + 0x27, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_39 (v39) + 0x01, 0x00, 0x00, 0x00, // universe = 1 + 0xFF, 0xFF, 0xFF, 0xFF, // package_id = 0xFFFFFFFF (terminator) + ]; + let result = parse_packageinfo(data); + assert!( + result.is_ok(), + "parse_packageinfo failed: {:?}", + result.err() + ); + let vdf = result.unwrap(); + assert_eq!(vdf.key(), "packageinfo_universe_1"); + let obj = vdf.as_obj().unwrap(); + assert_eq!(obj.len(), 0, "Should have no packages"); + } + + #[test] + fn test_parse_packageinfo_v40_with_terminator() { + // v40 format with immediate termination marker (no packages) + let data: &[u8] = &[ + 0x28, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_40 (v40) + 0x01, 0x00, 0x00, 0x00, // universe = 1 + 0xFF, 0xFF, 0xFF, 0xFF, // package_id = 0xFFFFFFFF (terminator) + ]; + let result = parse_packageinfo(data); + assert!( + result.is_ok(), + "parse_packageinfo failed: {:?}", + result.err() + ); + let vdf = result.unwrap(); + assert_eq!(vdf.key(), "packageinfo_universe_1"); + let obj = vdf.as_obj().unwrap(); + assert_eq!(obj.len(), 0, "Should have no packages"); + } + + #[test] + fn test_parse_packageinfo_v39_truncated_entry_header() { + // v39 format with package_id but incomplete header + // Header size for v39 is 4 + 20 + 4 = 28 bytes (package_id + hash + change_number) + let data: &[u8] = &[ + 0x27, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_39 + 0x00, 0x00, 0x00, 0x00, // universe + 0x01, 0x00, 0x00, 0x00, // package_id = 1 + // Only 10 bytes of hash (need 20), missing change_number + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + ]; + assert!(matches!( + parse_packageinfo(data), + Err(Error::UnexpectedEndOfInput { context, .. }) if context == "reading package entry header" + )); + } + + #[test] + fn test_parse_packageinfo_v40_truncated_entry_header() { + // v40 format with package_id but incomplete header + // Header size for v40 is 4 + 20 + 4 + 8 = 36 bytes (+ token) + let data: &[u8] = &[ + 0x28, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_40 + 0x00, 0x00, 0x00, 0x00, // universe + 0x01, 0x00, 0x00, 0x00, // package_id = 1 + // Only hash (20 bytes), missing change_number and token + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + ]; + assert!(matches!( + parse_packageinfo(data), + Err(Error::UnexpectedEndOfInput { context, .. }) if context == "reading package entry header" + )); + } + + #[test] + fn test_parse_packageinfo_v39_with_minimal_vdf() { + // v39 format with minimal VDF that tests basic parsing + let data: &[u8] = &[ + 0x27, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_39 + 0x00, 0x00, 0x00, 0x00, // universe = 0 + // Package entry + 0x01, 0x00, 0x00, 0x00, // package_id = 1 + // SHA-1 hash (20 bytes) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2A, 0x00, 0x00, 0x00, // change_number = 42 + // VDF: simple object with one string entry { "k": "value" } + 0x01, // String type + b'k', 0x00, // Key "k" + b'v', b'a', b'l', b'u', b'e', 0x00, // Value "value" + 0x08, // Object end + // Termination marker + 0xFF, 0xFF, 0xFF, 0xFF, + ]; + let result = parse_packageinfo(data); + assert!( + result.is_ok(), + "parse_packageinfo failed: {:?}", + result.err() + ); + let vdf = result.unwrap(); + assert_eq!(vdf.key(), "packageinfo_universe_0"); + + let obj = vdf.as_obj().unwrap(); + assert_eq!(obj.len(), 1); + let package = obj.get("1").and_then(|v| v.as_obj()).unwrap(); + assert_eq!(package.get("k").and_then(|v| v.as_str()), Some("value")); + } + + #[test] + fn test_parse_packageinfo_v40_with_minimal_vdf() { + // v40 format with minimal VDF + let data: &[u8] = &[ + 0x28, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_40 + 0x00, 0x00, 0x00, 0x00, // universe = 0 + // Package entry + 0x01, 0x00, 0x00, 0x00, // package_id = 1 + // SHA-1 hash (20 bytes) + 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0x2A, 0x00, 0x00, 0x00, // change_number = 42 + // PICS token (8 bytes) + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + // VDF: simple object with one int32 entry { "x": 5 } + 0x02, // Int32 type + b'x', 0x00, // Key "x" + 0x05, 0x00, 0x00, 0x00, // Value 5 + 0x08, // Object end + // Termination marker + 0xFF, 0xFF, 0xFF, 0xFF, + ]; + let result = parse_packageinfo(data); + assert!( + result.is_ok(), + "parse_packageinfo failed: {:?}", + result.err() + ); + let vdf = result.unwrap(); + assert_eq!(vdf.key(), "packageinfo_universe_0"); + + let obj = vdf.as_obj().unwrap(); + assert_eq!(obj.len(), 1); + let package = obj.get("1").and_then(|v| v.as_obj()).unwrap(); + assert_eq!(package.get("x").and_then(|v| v.as_i32()), Some(5)); + } + + #[test] + fn test_parse_packageinfo_multiple_packages() { + // v39 format with multiple packages + let data: &[u8] = &[ + 0x27, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_39 + 0x00, 0x00, 0x00, 0x00, // universe = 0 + // First package + 0x01, 0x00, 0x00, 0x00, // package_id = 1 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // hash + 0x01, 0x00, 0x00, 0x00, // change_number = 1 + // VDF: { "x": 1 } + 0x02, 0x01, 0x00, 0x00, 0x00, b'x', 0x00, 0x08, // Second package + 0x02, 0x00, 0x00, 0x00, // package_id = 2 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // hash + 0x02, 0x00, 0x00, 0x00, // change_number = 2 + // VDF: { "a": 2 } + 0x02, 0x02, 0x00, 0x00, 0x00, b'a', 0x00, 0x08, // Termination marker + 0xFF, 0xFF, 0xFF, 0xFF, + ]; + let result = parse_packageinfo(data); + assert!( + result.is_ok(), + "parse_packageinfo failed: {:?}", + result.err() + ); + let vdf = result.unwrap(); + let obj = vdf.as_obj().unwrap(); + assert_eq!(obj.len(), 2); + assert!(obj.get("1").is_some()); + assert!(obj.get("2").is_some()); + } + + #[test] + fn test_parse_packageinfo_empty_input() { + // Completely empty input + let data: &[u8] = &[]; + assert!(matches!( + parse_packageinfo(data), + Err(Error::UnexpectedEndOfInput { context, .. }) if context == "reading packageinfo header" + )); + } + + #[test] + fn test_parse_packageinfo_only_magic() { + // Only magic bytes, no universe + let data: &[u8] = &[ + 0x27, 0x55, 0x56, 0x06, // PACKAGEINFO_MAGIC_39 + ]; + assert!(matches!( + parse_packageinfo(data), + Err(Error::UnexpectedEndOfInput { .. }) + )); + } +} diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/binary/types.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/binary/types.rs new file mode 100644 index 0000000..4f39a8e --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/binary/types.rs @@ -0,0 +1,70 @@ +//! Type definitions for binary VDF format. + +/// Binary type byte values used in Steam's binary VDF format. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum BinaryType { + /// Nested object start marker. + /// + /// Named `None` to match Steam SDK's `TYPE_NONE`, which indicates a subsection + /// with child keys rather than a leaf value. In binary VDF, `0x00` followed by + /// a key name starts a new nested object that continues until `ObjectEnd` (0x08). + None = 0x00, + /// String value (null-terminated). + String = 0x01, + /// 32-bit integer value. + Int32 = 0x02, + /// 32-bit float value. + Float = 0x03, + /// Pointer value. + Ptr = 0x04, + /// Wide string value (UTF-16). + WString = 0x05, + /// Color value (RGBA). + Color = 0x06, + /// 64-bit unsigned integer value. + UInt64 = 0x07, + /// End of object marker. + ObjectEnd = 0x08, +} + +impl BinaryType { + /// Attempts to convert a byte to a `BinaryType`. + /// + /// Returns `None` if the byte doesn't correspond to a known type. + #[inline] + pub fn from_byte(b: u8) -> Option<Self> { + match b { + 0x00 => Some(BinaryType::None), + 0x01 => Some(BinaryType::String), + 0x02 => Some(BinaryType::Int32), + 0x03 => Some(BinaryType::Float), + 0x04 => Some(BinaryType::Ptr), + 0x05 => Some(BinaryType::WString), + 0x06 => Some(BinaryType::Color), + 0x07 => Some(BinaryType::UInt64), + 0x08 => Some(BinaryType::ObjectEnd), + _ => None, + } + } +} + +/// Magic number for appinfo.vdf format version 40. +/// +/// This format uses null-terminated UTF-8 keys. +pub const APPINFO_MAGIC_40: u32 = 0x07564428; + +/// Magic number for appinfo.vdf format version 41 (with string table). +/// +/// This format uses u32 indices into a string table for keys, enabling O(1) lookups. +pub const APPINFO_MAGIC_41: u32 = 0x07564429; + +/// Magic base for packageinfo.vdf format (upper 3 bytes). +pub const PACKAGEINFO_MAGIC_BASE: u32 = 0x065655; + +/// Magic number for packageinfo.vdf format version 39. +pub const PACKAGEINFO_MAGIC_39: u32 = 0x06565527; + +/// Magic number for packageinfo.vdf format version 40 (with PICS token). +pub const PACKAGEINFO_MAGIC_40: u32 = 0x06565528; |
