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 | |
| 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')
36 files changed, 5912 insertions, 0 deletions
diff --git a/libs/steam_appinfo_ffi/CMakeLists.txt b/libs/steam_appinfo_ffi/CMakeLists.txt new file mode 100644 index 0000000..46d117e --- /dev/null +++ b/libs/steam_appinfo_ffi/CMakeLists.txt @@ -0,0 +1,35 @@ +cmake_minimum_required(VERSION 3.16) +project(steam_appinfo_ffi) + +if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(CARGO_BUILD_TYPE "debug") + set(CARGO_BUILD_FLAGS "") +else() + set(CARGO_BUILD_TYPE "release") + set(CARGO_BUILD_FLAGS "--release") +endif() + +set(SAI_FFI_DIR ${CMAKE_CURRENT_SOURCE_DIR}) +set(SAI_FFI_LIB ${SAI_FFI_DIR}/target/${CARGO_BUILD_TYPE}/libsteam_appinfo_ffi.so) + +add_custom_command( + OUTPUT ${SAI_FFI_LIB} + COMMAND cargo build ${CARGO_BUILD_FLAGS} + WORKING_DIRECTORY ${SAI_FFI_DIR} + COMMENT "Building Steam appinfo FFI library (Rust)" + DEPENDS + ${SAI_FFI_DIR}/Cargo.toml + ${SAI_FFI_DIR}/src/lib.rs + ${SAI_FFI_DIR}/vendor/steam-vdf-parser/src/binary/parser.rs +) + +add_custom_target(steam_appinfo_ffi_build DEPENDS ${SAI_FFI_LIB}) + +add_library(steam_appinfo_ffi_wrapper INTERFACE) +target_link_libraries(steam_appinfo_ffi_wrapper INTERFACE ${SAI_FFI_LIB}) +target_include_directories(steam_appinfo_ffi_wrapper INTERFACE ${SAI_FFI_DIR}/include) +add_dependencies(steam_appinfo_ffi_wrapper steam_appinfo_ffi_build) +add_library(mo2::steam_appinfo_ffi ALIAS steam_appinfo_ffi_wrapper) + +install(FILES ${SAI_FFI_LIB} DESTINATION lib) +install(DIRECTORY ${SAI_FFI_DIR}/include/ DESTINATION include) diff --git a/libs/steam_appinfo_ffi/Cargo.lock b/libs/steam_appinfo_ffi/Cargo.lock new file mode 100644 index 0000000..7854128 --- /dev/null +++ b/libs/steam_appinfo_ffi/Cargo.lock @@ -0,0 +1,76 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mo2_steam_appinfo_ffi" +version = "0.1.0" +dependencies = [ + "steam-vdf-parser", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "steam-vdf-parser" +version = "0.1.1" +dependencies = [ + "foldhash", + "hex", + "indexmap", + "static_assertions", + "winnow", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] diff --git a/libs/steam_appinfo_ffi/Cargo.toml b/libs/steam_appinfo_ffi/Cargo.toml new file mode 100644 index 0000000..f97f9db --- /dev/null +++ b/libs/steam_appinfo_ffi/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "mo2_steam_appinfo_ffi" +version = "0.1.0" +edition = "2021" + +[lib] +name = "steam_appinfo_ffi" +crate-type = ["cdylib"] + +[dependencies] +steam-vdf-parser = { path = "vendor/steam-vdf-parser" } diff --git a/libs/steam_appinfo_ffi/include/steam_appinfo_ffi.h b/libs/steam_appinfo_ffi/include/steam_appinfo_ffi.h new file mode 100644 index 0000000..be53999 --- /dev/null +++ b/libs/steam_appinfo_ffi/include/steam_appinfo_ffi.h @@ -0,0 +1,24 @@ +#ifndef MO2_STEAM_APPINFO_FFI_H +#define MO2_STEAM_APPINFO_FFI_H + +#include <stdint.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/// Callback invoked once per app in appinfo.vdf. Strings are valid for the +/// duration of the call only — copy if you want to keep them. +typedef void (*SteamAppInfoCallback)(void* user, uint32_t appid, + const char* type, const char* name); + +/// Parse Steam's appinfo.vdf at `path` and invoke `cb` for every app. +/// Returns 0 on success, negative on error. +int32_t steam_appinfo_parse(const char* path, void* user, + SteamAppInfoCallback cb); + +#ifdef __cplusplus +} +#endif + +#endif // MO2_STEAM_APPINFO_FFI_H diff --git a/libs/steam_appinfo_ffi/src/lib.rs b/libs/steam_appinfo_ffi/src/lib.rs new file mode 100644 index 0000000..c134b97 --- /dev/null +++ b/libs/steam_appinfo_ffi/src/lib.rs @@ -0,0 +1,89 @@ +//! C FFI bridge to steam-vdf-parser for reading Steam's appinfo.vdf (binary, +//! including v41 / magic 0x07564429 with string table). +//! +//! Exposes a single function that streams `(appid, type, name)` tuples to a +//! C callback so the C++ side can build its own data structures without +//! needing to know the parser's internals. + +use std::ffi::{CStr, CString, c_char, c_void}; +use std::fs; + +use steam_vdf_parser::parse_appinfo; + +/// Callback invoked once per app. `user` is passed through unchanged. +/// Strings are valid for the duration of the call only — the C++ side must +/// copy whatever it wants to keep. +pub type SteamAppInfoCallback = + extern "C" fn(user: *mut c_void, appid: u32, type_: *const c_char, name: *const c_char); + +/// Parse the appinfo.vdf at `path_c` and call `cb(user, appid, type, name)` +/// for every app whose `common` section is readable. +/// +/// Returns 0 on success, negative on error: +/// -1 = path is null / not valid UTF-8 +/// -2 = file read error +/// -3 = parse error (unsupported magic, truncated, etc.) +/// +/// # Safety +/// `path_c` must be a valid null-terminated C string. `cb` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn steam_appinfo_parse( + path_c: *const c_char, + user: *mut c_void, + cb: SteamAppInfoCallback, +) -> i32 { + if path_c.is_null() { + return -1; + } + let path = match unsafe { CStr::from_ptr(path_c) }.to_str() { + Ok(s) => s, + Err(_) => return -1, + }; + + let data = match fs::read(path) { + Ok(d) => d, + Err(_) => return -2, + }; + + let vdf = match parse_appinfo(&data) { + Ok(v) => v.into_owned(), + Err(_) => return -3, + }; + + let root = match vdf.as_obj() { + Some(o) => o, + None => return -3, + }; + + for (app_id_str, app_value) in root.iter() { + let appid = match app_id_str.parse::<u32>() { + Ok(v) => v, + Err(_) => continue, + }; + + let app_obj = match app_value.as_obj() { + Some(o) => o, + None => continue, + }; + + let common = app_obj + .get("appinfo") + .and_then(|v| v.as_obj()) + .and_then(|appinfo| appinfo.get("common")) + .and_then(|v| v.as_obj()); + + let common = match common { + Some(c) => c, + None => continue, + }; + + let type_ = common.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let name = common.get("name").and_then(|v| v.as_str()).unwrap_or(""); + + let type_c = CString::new(type_).unwrap_or_default(); + let name_c = CString::new(name).unwrap_or_default(); + cb(user, appid, type_c.as_ptr(), name_c.as_ptr()); + } + + 0 +} diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/.github/workflows/ci.yml b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/.github/workflows/ci.yml new file mode 100644 index 0000000..4bd43a0 --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test (${{ matrix.rust }}) + runs-on: ubuntu-latest + strategy: + matrix: + rust: [stable, beta, "1.88"] + steps: + - uses: actions/checkout@v5 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ matrix.rust }} + - run: cargo test --all-features + + fmt: + name: Formatting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + components: rustfmt + - run: cargo fmt --all --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + components: clippy + - run: cargo clippy --all-targets --all-features -- -D warnings + + docs: + name: Documentation + runs-on: ubuntu-latest + env: + RUSTDOCFLAGS: -D warnings + steps: + - uses: actions/checkout@v5 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - run: cargo doc --no-deps --all-features diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/.gitignore b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/.gitignore @@ -0,0 +1 @@ +/target diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/Cargo.lock b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/Cargo.lock new file mode 100644 index 0000000..1c1268f --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/Cargo.lock @@ -0,0 +1,69 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "steam-vdf-parser" +version = "0.1.1" +dependencies = [ + "foldhash", + "hex", + "indexmap", + "static_assertions", + "winnow", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/Cargo.toml b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/Cargo.toml new file mode 100644 index 0000000..2bb0a0c --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "steam-vdf-parser" +version = "0.1.1" +edition = "2024" +rust-version = "1.88" +license = "MIT OR Apache-2.0" +description = "Zero-copy parser for Steam's VDF (Valve Data Format) files" +readme = "README.md" +keywords = ["vdf", "steam", "parser", "binary", "valve"] +categories = ["parser-implementations", "data-structures"] +repository = "https://github.com/mexus/steam-vdf-parser" +documentation = "https://docs.rs/steam-vdf-parser" + +[dependencies] +hex = { version = "0.4.3", default-features = false, features = ["alloc"] } +winnow = { version = "0.7.14", default-features = false, features = ["alloc", "simd"] } +indexmap = { version = "2.13.0", default-features = false } +foldhash = { version = "0.2.0", default-features = false } +static_assertions = "1.1.0" diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/LICENSE-APACHE b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/LICENSE-APACHE new file mode 100644 index 0000000..77242f2 --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/LICENSE-APACHE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +Copyright 2026 steam-vdf-parser contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/LICENSE-MIT b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/LICENSE-MIT new file mode 100644 index 0000000..c816fbd --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/LICENSE-MIT @@ -0,0 +1,19 @@ +Copyright 2026 steam-vdf-parser contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/README.md b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/README.md new file mode 100644 index 0000000..8108b7a --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/README.md @@ -0,0 +1,288 @@ +# steam-vdf-parser + +[](https://crates.io/crates/steam-vdf-parser) +[](https://docs.rs/steam-vdf-parser) +[](https://github.com/mexus/steam-vdf-parser#license) +[](https://github.com/mexus/steam-vdf-parser/actions/workflows/ci.yml) + +A blazing fast, zero-copy parser for Steam's VDF (Valve Data Format) files in Rust. + +Supports both text and binary formats used by Steam, including `shortcuts.vdf`, `appinfo.vdf`, and `packageinfo.vdf`. + +## Features + +- **`no_std` compatible** — works without the standard library, requires only `alloc` +- **Zero-copy parsing** — text format returns borrowed strings when possible (no escape sequences) +- **Binary format support** — parses all Steam binary VDF variants +- **Version-aware** — handles `appinfo.vdf` v40 (null-terminated keys) and v41 (string table) +- **`serde`-free** — simple, direct data structures with no hidden allocations + +## Usage + +### Text Format + +```rust +use steam_vdf_parser::parse_text; + +let input = r#""root" +{ + "key" "value" + "nested" + { + "subkey" "subvalue" + } +}"#; + +let vdf = parse_text(input)?; +assert_eq!(vdf.key(), "root"); + +// Access nested values +let obj = vdf.as_obj().unwrap(); +let value = obj.get("key").and_then(|v| v.as_str()).unwrap(); +assert_eq!(value, "value"); + +// Path-based access +let subvalue = vdf.get_str(&["nested", "subkey"]).unwrap(); +assert_eq!(subvalue, "subvalue"); +``` + +### Binary Format (auto-detect) + +```rust +use steam_vdf_parser::parse_binary; + +let data = read_vdf_data_somehow()?; +let vdf = parse_binary(&data)?; + +// For data that needs to outlive the input: +let owned = vdf.into_owned(); +``` + +### Specific Formats + +```rust +use steam_vdf_parser::{parse_appinfo, parse_packageinfo}; + +// appinfo.vdf (auto-detects v40/v41) +let data = read_vdf_data_somehow()?; +let vdf = parse_appinfo(&data)?; + +// packageinfo.vdf +let data = read_vdf_data_somehow()?; +let vdf = parse_packageinfo(&data)?; +``` + +## `no_std` Support + +This library is `no_std` compatible and only requires the `alloc` crate. Enable it in your `Cargo.toml`: + +```toml +[dependencies] +steam-vdf-parser = "0.1" +``` + +For `no_std` environments, make sure to have a global allocator configured: + +```rust +extern crate alloc; +use steam_vdf_parser::parse_text; + +// Your parsing code here +``` + +## Data Structures + +### `Vdf<'text>` + +The top-level VDF document. A VDF document is essentially a single key-value pair at the root level. + +```rust +let vdf = parse_text(input)?; + +// Access root key and value +let key: &str = vdf.key(); +let value: &Value = vdf.value(); + +// Check if root is an object +if vdf.is_obj() { + let obj = vdf.as_obj().unwrap(); +} + +// Direct nested access +let nested = vdf.get("key"); // Option<&Value> + +// Path-based traversal +let deep = vdf.get_path(&["nested", "deep", "value"]); +let name = vdf.get_str(&["config", "name"]); +let count = vdf.get_i32(&["settings", "count"]); +``` + +### `Value<'text>` Enum + +| Variant | Rust Type | Type Check | Accessor | +|---------|-----------|------------|----------| +| `Str` | `Cow<'text, str>` | `is_str()` | `as_str()` | +| `Obj` | `Obj<'text>` | `is_obj()` | `as_obj()`, `as_obj_mut()` | +| `I32` | `i32` | `is_i32()` | `as_i32()` | +| `U64` | `u64` | `is_u64()` | `as_u64()` | +| `Float` | `f32` | `is_float()` | `as_float()` | +| `Pointer` | `u32` | `is_pointer()` | `as_pointer()` | +| `Color` | `[u8; 4]` | `is_color()` | `as_color()` | + +Path-based access methods are also available on `Value`: + +```rust +// Traverse nested objects +let deep = value.get_path(&["nested", "key"]); + +// Typed path access +let name = value.get_str(&["config", "name"]); +let obj = value.get_obj(&["settings"]); +let count = value.get_i32(&["stats", "count"]); +let id = value.get_u64(&["user", "id"]); +let ratio = value.get_float(&["metrics", "ratio"]); +``` + +### `Obj<'text>` + +A `HashMap`-backed object (using `hashbrown` for `no_std` compatibility) with O(1) lookup: + +```rust +let obj = vdf.as_obj().unwrap(); + +// Basic access +let len = obj.len(); +let is_empty = obj.is_empty(); +let value = obj.get("key"); // Option<&Value> +let exists = obj.contains_key("key"); // bool + +// Iteration +for (key, value) in obj.iter() { + println!("{}: {}", key, value); +} +for key in obj.keys() { + println!("Key: {}", key); +} +for value in obj.values() { + println!("Value: {}", value); +} + +// Mutation +let mut obj = Obj::new(); +obj.insert("key", Value::Str("value".into())); +obj.get_mut("key"); // Option<&mut Value> +obj.remove("key"); // Option<Value> +``` + +### Lifetime and Ownership + +Parsing functions return `Vdf<'_>` with strings borrowed from input where possible. Use `.into_owned()` to convert to `Vdf<'static>`: + +```rust +let borrowed: Vdf<'_> = parse_text(input)?; +let owned: Vdf<'static> = borrowed.into_owned(); +``` + +## Binary Format Reference + +### Type Bytes + +All binary VDF formats use type byte prefixes: + +| Byte | Name | Description | +|------|------|-------------| +| `0x00` | None/Object | Start of an object (followed by key) | +| `0x01` | String | Null-terminated UTF-8 string value | +| `0x02` | Int32 | 4-byte little-endian signed integer | +| `0x03` | Float | 4-byte little-endian IEEE-754 float | +| `0x04` | Pointer | 4-byte pointer value (stored as `u32`) | +| `0x05` | WString | Null-terminated UTF-16LE string (`0x00 0x00` terminator) | +| `0x06` | Color | 4 bytes RGBA | +| `0x07` | UInt64 | 8-byte little-endian unsigned integer | +| `0x08` | ObjectEnd | End of current object | + +### Entry Format + +`[TypeByte] [Key] [Value...]` + +- **Key encoding** varies by format (null-terminated or string table index) +- **String values** are always inline null-terminated UTF-8 (never from string table) + +### shortcuts.vdf + +Simple binary format. Keys are null-terminated UTF-8 strings. Ends at EOF. + +### appinfo.vdf + +#### File Header + +| Offset | Size | Field | Description | +|--------|------|-------|-------------| +| `0x00` | 4 | Magic | `0x07564428` (v40) or `0x07564429` (v41) | +| `0x04` | 4 | Universe | Always `1` (public) | +| `0x08` | 8 | String Table Offset | Present only in v41 | + +#### App Entry Header (68 bytes) + +| Offset | Size | Field | Description | +|--------|------|-------|-------------| +| `0x00` | 4 | App ID | Steam application ID | +| `0x04` | 4 | Size | Size of remaining data (60 bytes + VDF payload) | +| `0x08` | 4 | Info State | Flags (e.g., `2` = available) | +| `0x0C` | 4 | Last Updated | Unix timestamp | +| `0x10` | 8 | PICS Token | Access token for PICS API | +| `0x18` | 20 | SHA1 | Hash of VDF payload | +| `0x2C` | 4 | Change Number | Sequence number | +| `0x30` | 20 | Binary SHA1 | Hash of binary VDF data | + +VDF data starts at offset `0x44` (68 bytes). Length = `Size - 60`. + +#### Key Encoding + +- **v40**: Keys are null-terminated UTF-8 strings +- **v41**: Keys are `u32` indices into the string table +- **All versions**: String *values* are always inline null-terminated UTF-8 + +#### String Table (v41 only) + +Located at `String Table Offset`: + +| Offset | Size | Field | Description | +|--------|------|-------|-------------| +| `0x00` | 4 | String Count | Number of strings (little-endian) | +| `0x04` | - | Strings | Null-terminated UTF-8 strings | + +### packageinfo.vdf + +#### File Header + +| Offset | Size | Field | Description | +|--------|------|-------|-------------| +| `0x00` | 4 | Magic | Upper 3 bytes: `0x065655`, lower byte: version (`27` = v39, `28` = v40) | +| `0x04` | 4 | Universe | Always `1` (public) | + +#### Package Entry Header + +| Offset | Size | Field | Description | +|--------|------|-------|-------------| +| `0x00` | 4 | Package ID | `0xFFFFFFFF` marks end of file | +| `0x04` | 20 | SHA-1 | Hash of VDF payload | +| `0x18` | 4 | Change Number | Sequence number | +| `0x1C` | 8 | PICS Token | Present only in v40 | + +Followed by binary VDF blob (null-terminated keys, like shortcuts.vdf). + +## Performance + +Text parsing is zero-copy when strings contain no escape sequences — `Cow::Borrowed` refers directly into the input. Escape sequences and wide strings (UTF-16) cause allocation (`Cow::Owned`). + +Binary parsing is also zero-copy for string values. In appinfo v41, root keys (app IDs) are owned due to integer-to-string conversion, but nested values remain borrowed from the string table or input buffer. + +## License + +Licensed under either of: + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) + +at your option. diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/appid_to_name.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/appid_to_name.rs new file mode 100644 index 0000000..6393869 --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/appid_to_name.rs @@ -0,0 +1,103 @@ +// Example: Extract AppId to game name mapping from Steam's appinfo.vdf +// +// Usage: +// cargo run --example appid_to_name -- path/to/appinfo.vdf +// +// The appinfo.vdf file is typically located at: +// - Windows: C:\Program Files (x86)\Steam\appcache\appinfo.vdf +// - Linux: ~/.steam/steam/appcache/appinfo.vdf +// - macOS: ~/Library/Application Support/Steam/appcache/appinfo.vdf + +use std::env; +use std::fs; +use std::process::ExitCode; + +use steam_vdf_parser::parse_appinfo; + +fn main() -> ExitCode { + let args: Vec<String> = env::args().collect(); + + if args.len() < 2 { + eprintln!("Usage: {} <path/to/appinfo.vdf>", args[0]); + eprintln!(); + eprintln!("Extracts AppId to game name mappings from Steam's appinfo.vdf file."); + eprintln!(); + eprintln!("The appinfo.vdf file is typically located at:"); + eprintln!(" Windows: C:\\Program Files (x86)\\Steam\\appcache\\appinfo.vdf"); + eprintln!(" Linux: ~/.steam/steam/appcache/appinfo.vdf"); + eprintln!(" macOS: ~/Library/Application Support/Steam/appcache/appinfo.vdf"); + return ExitCode::FAILURE; + } + + let path = &args[1]; + + // Read the file + let data = match fs::read(path) { + Ok(data) => data, + Err(e) => { + eprintln!("Error reading file '{}': {}", path, e); + return ExitCode::FAILURE; + } + }; + + // Parse the appinfo.vdf file + let vdf = match parse_appinfo(&data) { + Ok(vdf) => vdf.into_owned(), + Err(e) => { + eprintln!("Error parsing appinfo.vdf: {}", e); + return ExitCode::FAILURE; + } + }; + + // Get the root object containing all apps + let root = match vdf.as_obj() { + Some(obj) => obj, + None => { + eprintln!("Error: root is not an object"); + return ExitCode::FAILURE; + } + }; + + // Iterate through all apps (keyed by AppID as string) + let mut apps = Vec::new(); + + for (app_id_str, app_value) in root.iter() { + // Skip non-numeric keys (metadata entries) + if app_id_str.parse::<u32>().is_err() { + continue; + } + + let app_obj = match app_value.as_obj() { + Some(obj) => obj, + None => continue, + }; + + // Navigate the nested structure: appinfo -> common -> name + let name = app_obj + .get("appinfo") + .and_then(|v| v.as_obj()) + .and_then(|appinfo| appinfo.get("common")) + .and_then(|common| common.as_obj()) + .and_then(|common| common.get("name")) + .and_then(|v| v.as_str()); + + if let (Some(name), Ok(app_id)) = (name, app_id_str.parse::<u32>()) { + apps.push((app_id, name.to_string())); + } + } + + // Sort by AppID + apps.sort_by_key(|(id, _)| *id); + + // Print the results + println!("AppId\tName"); + println!("------\t{}", "-".repeat(80)); + for (app_id, name) in &apps { + println!("{}\t{}", app_id, name); + } + + println!(); + println!("Total games: {}", apps.len()); + + ExitCode::SUCCESS +} diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/compactify_vdf.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/compactify_vdf.rs new file mode 100644 index 0000000..64a94ee --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/compactify_vdf.rs @@ -0,0 +1,242 @@ +//! Compactify an appinfo.vdf file by keeping only the first N apps. +//! +//! Usage: +//! cargo run --example compactify_vdf <input.vdf> <output.vdf> [--count N] +//! +//! Default count is 5. + +use std::env; +use std::fs; +use std::path::Path; + +use steam_vdf_parser::binary::{ + APPINFO_MAGIC_40, APPINFO_MAGIC_41, read_u32_le_at, read_u64_le_at, +}; + +// App entry header size +const APPINFO_ENTRY_HEADER_SIZE: usize = 68; + +fn main() { + let args: Vec<String> = env::args().collect(); + + if args.len() < 3 { + eprintln!("Usage: {} <input.vdf> <output.vdf> [--count N]", args[0]); + eprintln!(" --count N: Keep only the first N apps (default: 5)"); + std::process::exit(1); + } + + let input_path = Path::new(&args[1]); + let output_path = Path::new(&args[2]); + + // Parse count argument + let mut count: usize = 5; + if args.len() >= 4 { + if args[3] == "--count" { + if args.len() < 5 { + eprintln!("Error: --count requires a number"); + std::process::exit(1); + } + count = match args[4].parse::<usize>() { + Ok(n) if n > 0 => n, + _ => { + eprintln!("Error: count must be a positive integer"); + std::process::exit(1); + } + }; + } else { + eprintln!("Error: unknown argument {}", args[3]); + std::process::exit(1); + } + } + + // Read input file + let data = match fs::read(input_path) { + Ok(d) => d, + Err(e) => { + eprintln!("Error reading input file: {}", e); + std::process::exit(1); + } + }; + + // Parse header + if data.len() < 16 { + eprintln!("Error: file too small to be a valid appinfo.vdf"); + std::process::exit(1); + } + + let magic = match read_u32_le_at(&data, 0) { + Some(m) => m, + None => { + eprintln!("Error: cannot read magic number"); + std::process::exit(1); + } + }; + + let universe = match read_u32_le_at(&data, 4) { + Some(u) => u, + None => { + eprintln!("Error: cannot read universe"); + std::process::exit(1); + } + }; + + let (is_v41, string_table_offset) = match magic { + APPINFO_MAGIC_40 => (false, None), + APPINFO_MAGIC_41 => { + let offset = read_u64_le_at(&data, 8); + (true, offset.map(|o| o as usize)) + } + _ => { + eprintln!( + "Error: invalid magic number {:08x}, expected appinfo.vdf format", + magic + ); + std::process::exit(1); + } + }; + + if is_v41 && string_table_offset.is_none() { + eprintln!("Error: cannot read string table offset for v41 format"); + std::process::exit(1); + } + + println!( + "Detected appinfo.vdf version: {}", + if is_v41 { 41 } else { 40 } + ); + println!("Universe: {}", universe); + if let Some(offset) = string_table_offset { + println!("String table offset: {}", offset); + } + + // Find app entries to keep + // App entries start at offset 16 (after magic + universe + optional string table offset) + const HEADER_SIZE: usize = 16; + + let mut apps_end = data.len(); + if let Some(offset) = string_table_offset { + apps_end = offset; + } + + let mut current_offset = HEADER_SIZE; + let mut selected_apps: Vec<(usize, usize)> = Vec::new(); // (start, size) for each app + + for _ in 0..count { + if current_offset >= apps_end { + break; + } + + // Check we have enough data for the entry header + if current_offset + APPINFO_ENTRY_HEADER_SIZE > data.len() { + eprintln!("Warning: incomplete app entry at offset {}", current_offset); + break; + } + + // Read app ID + let app_id = match read_u32_le_at(&data, current_offset) { + Some(id) => id, + None => { + eprintln!("Error: cannot read app_id at offset {}", current_offset); + std::process::exit(1); + } + }; + + // Check for terminator + if app_id == 0 { + println!( + "Reached terminator (app_id == 0) at offset {}", + current_offset + ); + break; + } + + // Read size field (at offset 4) + let entry_size = match read_u32_le_at(&data, current_offset + 4) { + Some(s) => s as usize, + None => { + eprintln!("Error: cannot read size at offset {}", current_offset + 4); + std::process::exit(1); + } + }; + + // Total size of this app entry = header (8) + size field value + // The size field includes APPINFO_HEADER_AFTER_SIZE (60) + VDF data + let total_entry_size = 8 + entry_size; + + // Verify we have enough data + if current_offset + total_entry_size > apps_end { + eprintln!( + "Warning: app entry extends past string table/EOF at offset {}", + current_offset + ); + break; + } + + println!( + "Selecting app_id {} at offset {}, size {}", + app_id, current_offset, total_entry_size + ); + + selected_apps.push((current_offset, total_entry_size)); + current_offset += total_entry_size; + } + + if selected_apps.is_empty() { + eprintln!("Error: no app entries found in file"); + std::process::exit(1); + } + + println!("Selected {} app entries", selected_apps.len()); + + // Build output file + let mut output = Vec::new(); + + // Write header + output.extend_from_slice(&data[0..HEADER_SIZE]); + + // Update string table offset for v41 (will be calculated later) + let string_table_offset_placeholder = if is_v41 { Some(output.len() - 8) } else { None }; + + // Write selected app entries + for (offset, size) in &selected_apps { + output.extend_from_slice(&data[*offset..*offset + *size]); + } + + // For v41: copy string table + // For v40: add terminator (4 bytes of 0x00) + if is_v41 { + let string_table_offset = string_table_offset.unwrap(); + let string_table_data = &data[string_table_offset..]; + + // Update string table offset in header + let new_offset = output.len() as u64; + let offset_bytes = new_offset.to_le_bytes(); + if let Some(pos) = string_table_offset_placeholder { + output[pos..pos + 8].copy_from_slice(&offset_bytes); + } + + println!( + "String table at original offset {}, new offset {}", + string_table_offset, new_offset + ); + + // Copy string table + output.extend_from_slice(string_table_data); + } else { + // v40: add terminator + output.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); + } + + // Write output file + if let Err(e) = fs::write(output_path, &output) { + eprintln!("Error writing output file: {}", e); + std::process::exit(1); + } + + println!( + "Wrote {} bytes (original: {} bytes, reduction: {}%)", + output.len(), + data.len(), + (data.len() - output.len()) * 100 / data.len() + ); +} diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/dump_vdf.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/dump_vdf.rs new file mode 100644 index 0000000..d0ee2e4 --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/dump_vdf.rs @@ -0,0 +1,106 @@ +use std::env; +use std::path::Path; +use steam_vdf_parser::{binary, parse_binary, parse_text}; + +fn dump_value(value: &steam_vdf_parser::Value, indent: usize) -> String { + let indent_str = " ".repeat(indent); + match value { + steam_vdf_parser::Value::Str(s) => format!("{}\"{}\"", indent_str, s), + steam_vdf_parser::Value::I32(n) => format!("{}{}", indent_str, n), + steam_vdf_parser::Value::U64(n) => format!("{}{}", indent_str, n), + steam_vdf_parser::Value::Float(n) => format!("{}{}", indent_str, n), + steam_vdf_parser::Value::Pointer(n) => format!("{}(pointer: {})", indent_str, n), + steam_vdf_parser::Value::Color(c) => format!( + "{}(color: #{:02x}{:02x}{:02x}{:02x})", + indent_str, c[0], c[1], c[2], c[3] + ), + steam_vdf_parser::Value::Obj(obj) => { + let mut out = format!("{}{{\n", indent_str); + for (k, v) in obj.iter() { + out.push_str(&format!("{}\"\"{}\"\": ", indent_str, k)); + match v { + steam_vdf_parser::Value::Obj(_) => { + out.push_str(&dump_value(v, indent + 1)); + } + steam_vdf_parser::Value::Str(s) => out.push_str(&format!("\"{}\"\n", s)), + steam_vdf_parser::Value::I32(n) => out.push_str(&format!("{}\n", n)), + steam_vdf_parser::Value::U64(n) => out.push_str(&format!("{}\n", n)), + steam_vdf_parser::Value::Float(n) => out.push_str(&format!("{}\n", n)), + steam_vdf_parser::Value::Pointer(n) => { + out.push_str(&format!("(pointer: {})\n", n)) + } + steam_vdf_parser::Value::Color(c) => out.push_str(&format!( + "(color: #{:02x}{:02x}{:02x}{:02x})\n", + c[0], c[1], c[2], c[3] + )), + } + } + out.push_str(&format!("{}}}\n", indent_str)); + out + } + } +} + +fn main() { + let args: Vec<String> = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} <vdf_file> [--text]", args[0]); + eprintln!( + " --text: force text format parsing (default: auto-detect based on file extension)" + ); + std::process::exit(1); + } + + let path = Path::new(&args[1]); + let force_text = args.len() > 2 && args[2] == "--text"; + + let result = if force_text || path.extension().is_some_and(|e| e == "vdf") { + // Try text first for .vdf files + let content = std::fs::read_to_string(path); + if let Ok(content) = content { + parse_text(&content).map(|v| v.into_owned()) + } else { + // Fall back to binary + let data = std::fs::read(path).expect("Failed to read file"); + if path + .file_name() + .is_some_and(|n| n.to_str().is_some_and(|s| s.contains("packageinfo"))) + { + binary::parse_packageinfo(&data).map(|v| v.into_owned()) + } else if path + .file_name() + .is_some_and(|n| n.to_str().is_some_and(|s| s.contains("appinfo"))) + { + binary::parse_appinfo(&data).map(|v| v.into_owned()) + } else { + parse_binary(&data).map(|v| v.into_owned()) + } + } + } else { + // Binary parsing + let data = std::fs::read(path).expect("Failed to read file"); + if path + .file_name() + .is_some_and(|n| n.to_str().is_some_and(|s| s.contains("packageinfo"))) + { + binary::parse_packageinfo(&data).map(|v| v.into_owned()) + } else if path + .file_name() + .is_some_and(|n| n.to_str().is_some_and(|s| s.contains("appinfo"))) + { + binary::parse_appinfo(&data).map(|v| v.into_owned()) + } else { + parse_binary(&data).map(|v| v.into_owned()) + } + }; + + match result { + Ok(vdf) => { + println!("\"{}\" {}", vdf.key(), dump_value(vdf.value(), 0)); + } + Err(e) => { + eprintln!("Error parsing VDF: {:?}", e); + std::process::exit(1); + } + } +} diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/print.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/print.rs new file mode 100644 index 0000000..b022a1f --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/print.rs @@ -0,0 +1,59 @@ +//! Pretty-print a VDF file using the `{:#}` Display format. +//! +//! Usage: cargo run --example pretty_print <vdf_file> +//! +//! This example demonstrates the pretty-print Display implementation that +//! outputs valid VDF text format with proper indentation and escaping. + +use std::env; +use std::path::Path; +use steam_vdf_parser::{binary, parse_binary, parse_text}; + +fn main() { + let args: Vec<String> = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} <vdf_file>", args[0]); + eprintln!(); + eprintln!("Pretty-prints a VDF file to stdout in valid VDF text format."); + eprintln!( + "Supports both text (.vdf) and binary (appinfo.vdf, packageinfo.vdf, shortcuts.vdf) formats." + ); + std::process::exit(1); + } + + let path = Path::new(&args[1]); + let data = std::fs::read(path).expect("Failed to read file"); + + // Try to detect format and parse accordingly + let result = if let Ok(text) = std::str::from_utf8(&data) { + // Looks like text, try text parser first + parse_text(text) + .map(|v| v.into_owned()) + .or_else(|_| parse_binary(&data).map(|v| v.into_owned())) + } else { + // Binary data - detect format from filename + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + + if filename.contains("packageinfo") { + binary::parse_packageinfo(&data).map(|v| v.into_owned()) + } else if filename.contains("appinfo") { + binary::parse_appinfo(&data).map(|v| v.into_owned()) + } else { + parse_binary(&data).map(|v| v.into_owned()) + } + }; + + match result { + Ok(vdf) => { + // Use the pretty-print Display implementation + println!("{:#}", vdf); + } + Err(e) => { + eprintln!("Error parsing VDF: {:?}", e); + std::process::exit(1); + } + } +} diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/test_parse_real.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/test_parse_real.rs new file mode 100644 index 0000000..97752d4 --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/test_parse_real.rs @@ -0,0 +1,41 @@ +use std::fs; +use steam_vdf_parser::{parse_binary, parse_text}; + +fn main() { + // Test localconfig.vdf (text format) + println!("=== Parsing localconfig.vdf (text format) ==="); + let localconfig = fs::read_to_string( + "/home/mexus/.local/share/Steam/userdata/127648749/config/localconfig.vdf", + ); + match localconfig { + Ok(content) => match parse_text(&content) { + Ok(vdf) => { + println!("Success!"); + println!("Root key: {}", vdf.key()); + let obj = vdf.as_obj().unwrap(); + println!("Root has {} keys", obj.len()); + } + Err(e) => { + println!("Parse error: {:?}", e); + } + }, + Err(e) => println!("Error reading: {}", e), + } + + println!("\n=== Parsing appinfo.vdf (binary format) ==="); + let appinfo = fs::read("/home/mexus/.local/share/Steam/appcache/appinfo.vdf"); + match appinfo { + Ok(data) => match parse_binary(&data) { + Ok(vdf) => { + println!("Success!"); + println!("Root key: {}", vdf.key()); + let obj = vdf.as_obj().unwrap(); + println!("Root has {} keys", obj.len()); + } + Err(e) => { + println!("Parse error: {:?}", e); + } + }, + Err(e) => println!("Error reading: {}", e), + } +} diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/verify_compactified.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/verify_compactified.rs new file mode 100644 index 0000000..c36f917 --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/examples/verify_compactified.rs @@ -0,0 +1,45 @@ +//! Verify that a compactified appinfo.vdf file parses correctly. + +use std::env; +use steam_vdf_parser::binary::parse_appinfo; + +fn main() { + let args: Vec<String> = env::args().collect(); + + if args.len() < 2 { + eprintln!("Usage: {} <vdf_file>", args[0]); + std::process::exit(1); + } + + let path = &args[1]; + + // Read the file + let data = match std::fs::read(path) { + Ok(d) => d, + Err(e) => { + eprintln!("Error reading file: {}", e); + std::process::exit(1); + } + }; + + // Parse it + match parse_appinfo(&data) { + Ok(vdf) => { + println!("Successfully parsed {}", path); + println!("Root key: {}", vdf.key()); + if let Some(obj) = vdf.as_obj() { + println!("Number of apps: {}", obj.len()); + for (key, _) in obj.iter().take(5) { + println!(" - app_id: {}", key); + } + if obj.len() > 5 { + println!(" ... and {} more", obj.len() - 5); + } + } + } + Err(e) => { + eprintln!("Error parsing file: {:?}", e); + std::process::exit(1); + } + } +} 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; diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/error.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/error.rs new file mode 100644 index 0000000..f2a8713 --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/error.rs @@ -0,0 +1,410 @@ +//! Error types for VDF parsing. + +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; +use core::fmt; + +/// Result type for VDF operations. +pub type Result<T> = core::result::Result<T, Error>; + +/// Create a parse error with truncated input snippet (max 50 chars). +/// +/// The snippet is limited to 50 characters to keep error messages manageable. +/// +/// # Parameters +/// - `input`: The input string being parsed +/// - `offset`: Byte offset where the error occurred +/// - `context`: Description of what was expected +pub fn parse_error(input: &str, offset: usize, context: impl Into<String>) -> Error { + let snippet = input.chars().take(50).collect::<String>(); + Error::ParseError { + input: snippet, + offset, + context: context.into(), + } +} + +/// Errors that can occur during VDF parsing. +#[derive(Debug)] +pub enum Error { + /// Binary format errors + /// --------------------- + + /// Invalid magic number in binary VDF header. + InvalidMagic { + /// The magic number that was found. + found: u32, + /// Expected magic numbers for this format. + expected: &'static [u32], + }, + + /// Unknown type byte encountered. + UnknownType { + /// The type byte that was found. + type_byte: u8, + /// Offset in the input where this occurred. + offset: usize, + }, + + /// Invalid string index into the string table. + InvalidStringIndex { + /// The index that was requested. + index: usize, + /// The maximum valid index. + max: usize, + }, + + /// Unexpected end of input while parsing. + UnexpectedEndOfInput { + /// Description of what was being read. + context: &'static str, + /// Offset in the input where this occurred. + offset: usize, + /// Expected minimum number of bytes. + expected: usize, + /// Actual number of bytes available. + actual: usize, + }, + + /// Invalid UTF-8 sequence in binary data. + InvalidUtf8 { + /// Offset where the error occurred. + offset: usize, + }, + + /// Invalid UTF-16 sequence in binary data. + InvalidUtf16 { + /// Offset where the error occurred. + offset: usize, + /// Position of the unpaired surrogate. + position: usize, + }, + + /// Text format errors + /// ------------------ + + /// Parse error with context. + ParseError { + /// A snippet of the input near the error. + input: String, + /// Offset in the input where this occurred. + offset: usize, + /// Context describing what was expected. + context: String, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::InvalidMagic { found, expected } => { + let expected_str: String = expected + .iter() + .map(|v| format!("0x{:08x}", v)) + .collect::<Vec<_>>() + .join(" or "); + write!( + f, + "Invalid magic number: expected {}, found 0x{:08x}", + expected_str, found + ) + } + Error::UnknownType { type_byte, offset } => { + write!( + f, + "Unknown type byte 0x{:02x} at offset {}", + type_byte, offset + ) + } + Error::InvalidStringIndex { index, max } => { + if *max == 0 { + write!(f, "Invalid string index {}: string table is empty", index) + } else { + write!( + f, + "Invalid string index {}: string table has {} entries (valid range: 0..{})", + index, max, max + ) + } + } + Error::UnexpectedEndOfInput { + context, + offset, + expected, + actual, + } => { + write!( + f, + "Unexpected end of input at offset {} while {}: expected {} bytes, found {}", + offset, context, expected, actual + ) + } + Error::InvalidUtf8 { offset } => { + write!(f, "Invalid UTF-8 sequence at offset {}", offset) + } + Error::InvalidUtf16 { offset, position } => { + write!( + f, + "Invalid UTF-16 sequence at offset {} (surrogate position {})", + offset, position + ) + } + Error::ParseError { + input, + offset, + context, + } => { + let snippet = if input.len() > 50 { + format!("{}...", &input[..50]) + } else { + input.clone() + }; + write!( + f, + "Parse error at offset {}: {} (near: \"{}\")", + offset, context, snippet + ) + } + } + } +} + +impl core::error::Error for Error {} + +impl Error { + /// Adjusts the offset in error variants that contain position information. + /// + /// This is used to add a base offset when parsing from a sub-slice, + /// converting relative offsets to absolute offsets in the original input. + fn with_offset(mut self, base: usize) -> Self { + match &mut self { + Error::UnexpectedEndOfInput { offset, .. } => *offset += base, + Error::InvalidUtf8 { offset } => *offset += base, + Error::InvalidUtf16 { offset, .. } => *offset += base, + Error::UnknownType { offset, .. } => *offset += base, + Error::ParseError { offset, .. } => *offset += base, + // Other variants don't have offsets to adjust + Error::InvalidMagic { .. } | Error::InvalidStringIndex { .. } => {} + } + self + } +} + +/// Returns a closure that adds an offset to an error. +/// +/// This is used with `.map_err()` to adjust error offsets when parsing from sub-slices. +pub(crate) fn with_offset(base: usize) -> impl Fn(Error) -> Error { + move |err| err.with_offset(base) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::binary::{APPINFO_MAGIC_40, APPINFO_MAGIC_41}; + use alloc::format; + use alloc::string::ToString; + + #[test] + fn test_error_display_invalid_magic() { + let err = Error::InvalidMagic { + found: 0xDEADBEEF, + expected: &[APPINFO_MAGIC_40, APPINFO_MAGIC_41], + }; + let msg = format!("{}", err); + assert!(msg.contains("0xdeadbeef")); + assert!(msg.contains("0x07564428")); + } + + #[test] + fn test_error_display_unknown_type() { + let err = Error::UnknownType { + type_byte: 0xFF, + offset: 42, + }; + let msg = format!("{}", err); + assert!(msg.contains("0xff")); + assert!(msg.contains("42")); + } + + #[test] + fn test_error_display_invalid_string_index() { + let err = Error::InvalidStringIndex { index: 5, max: 3 }; + let msg = format!("{}", err); + assert!(msg.contains("5")); + assert!(msg.contains("3")); + } + + #[test] + fn test_error_display_invalid_string_index_empty() { + let err = Error::InvalidStringIndex { index: 0, max: 0 }; + let msg = format!("{}", err); + assert!(msg.contains("empty")); + } + + #[test] + fn test_error_display_unexpected_end_of_input() { + let err = Error::UnexpectedEndOfInput { + context: "reading string", + offset: 10, + expected: 4, + actual: 2, + }; + let msg = format!("{}", err); + assert!(msg.contains("reading string")); + assert!(msg.contains("10")); + assert!(msg.contains("expected 4")); + assert!(msg.contains("found 2")); + } + + #[test] + fn test_error_display_invalid_utf8() { + let err = Error::InvalidUtf8 { offset: 15 }; + let msg = format!("{}", err); + assert!(msg.contains("15")); + } + + #[test] + fn test_error_display_invalid_utf16() { + let err = Error::InvalidUtf16 { + offset: 20, + position: 3, + }; + let msg = format!("{}", err); + assert!(msg.contains("20")); + assert!(msg.contains("3")); + } + + #[test] + fn test_error_display_parse_error() { + let err = Error::ParseError { + input: "some very long input that should be truncated in the message".to_string(), + offset: 5, + context: "expected quote".to_string(), + }; + let msg = format!("{}", err); + assert!(msg.contains("5")); + assert!(msg.contains("expected quote")); + // The input should be truncated to 50 chars, so the message shouldn't be too long + assert!(msg.len() < 150); + } + + #[test] + fn test_error_with_offset_unexpected_end() { + let err = Error::UnexpectedEndOfInput { + context: "test", + offset: 10, + expected: 4, + actual: 2, + }; + let adjusted = err.with_offset(100); + match adjusted { + Error::UnexpectedEndOfInput { offset, .. } => { + assert_eq!(offset, 110); + } + _ => panic!("Unexpected error type"), + } + } + + #[test] + fn test_error_with_offset_invalid_utf8() { + let err = Error::InvalidUtf8 { offset: 5 }; + let adjusted = err.with_offset(100); + match adjusted { + Error::InvalidUtf8 { offset } => { + assert_eq!(offset, 105); + } + _ => panic!("Unexpected error type"), + } + } + + #[test] + fn test_error_with_offset_invalid_utf16() { + let err = Error::InvalidUtf16 { + offset: 10, + position: 2, + }; + let adjusted = err.with_offset(100); + match adjusted { + Error::InvalidUtf16 { offset, position } => { + assert_eq!(offset, 110); + assert_eq!(position, 2); + } + _ => panic!("Unexpected error type"), + } + } + + #[test] + fn test_error_with_offset_unknown_type() { + let err = Error::UnknownType { + type_byte: 0x42, + offset: 7, + }; + let adjusted = err.with_offset(100); + match adjusted { + Error::UnknownType { type_byte, offset } => { + assert_eq!(type_byte, 0x42); + assert_eq!(offset, 107); + } + _ => panic!("Unexpected error type"), + } + } + + #[test] + fn test_error_with_offset_parse_error() { + let err = Error::ParseError { + input: "test".to_string(), + offset: 3, + context: "context".to_string(), + }; + let adjusted = err.with_offset(100); + match adjusted { + Error::ParseError { offset, .. } => { + assert_eq!(offset, 103); + } + _ => panic!("Unexpected error type"), + } + } + + #[test] + fn test_error_with_offset_no_change_for_non_offset_variants() { + let err = Error::InvalidMagic { + found: 0x12345678, + expected: &[APPINFO_MAGIC_40], + }; + let adjusted = err.with_offset(100); + // InvalidMagic doesn't have an offset field, so it should be unchanged + match adjusted { + Error::InvalidMagic { found, .. } => { + assert_eq!(found, 0x12345678); + } + _ => panic!("Unexpected error type"), + } + } + + #[test] + fn test_parse_error_truncates_long_input() { + let long_input = "a".repeat(100); + let err = parse_error(&long_input, 0, "test context"); + match err { + Error::ParseError { input, .. } => { + assert!(input.len() <= 50, "Input should be truncated to 50 chars"); + } + _ => panic!("Expected ParseError variant"), + } + } + + #[test] + fn test_with_offset_closure() { + let base_offset = 100; + let f = with_offset(base_offset); + let err = Error::InvalidUtf8 { offset: 5 }; + let adjusted = f(err); + match adjusted { + Error::InvalidUtf8 { offset } => { + assert_eq!(offset, 105); + } + _ => panic!("Unexpected error type"), + } + } +} diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/lib.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/lib.rs new file mode 100644 index 0000000..edf4307 --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/lib.rs @@ -0,0 +1,220 @@ +//! Blazing fast VDF (Valve Data Format) parser. +//! +//! This library provides parsers for both text and binary VDF formats used by Steam and +//! other Valve Software products. +//! +//! # Features +//! +//! - **`no_std` compatible** — works without the standard library, requires only `alloc` +//! - **Zero-copy parsing** for text format when possible (no escape sequences) +//! - **Binary format support** for Steam's appinfo.vdf, shortcuts.vdf, and packageinfo.vdf +//! - **Winnow-powered** text parser for maximum performance +//! +//! # Example +//! +//! ``` +//! use steam_vdf_parser::parse_text; +//! +//! let input = r#""root" +//! { +//! "key" "value" +//! "nested" +//! { +//! "subkey" "subvalue" +//! } +//! }"#; +//! +//! let vdf = parse_text(input).unwrap(); +//! ``` + +#![no_std] +#![warn(missing_docs)] + +extern crate alloc; + +use alloc::borrow::Cow; +use alloc::string::ToString; + +pub mod binary; +pub mod error; +pub mod text; +pub mod value; + +pub use error::{Error, Result}; +pub use value::{Obj, Value, Vdf}; + +// Re-export commonly used functions +pub use text::parse_text; + +/// Parse VDF from binary format (autodetects shortcuts or appinfo format). +/// +/// This function returns zero-copy data where possible - strings are borrowed +/// from the input buffer. Use `.into_owned()` to convert to an owned `Vdf<'static>`. +pub fn parse_binary(input: &[u8]) -> Result<Vdf<'_>> { + binary::parse(input) +} + +/// Parse a shortcuts.vdf format binary file. +/// +/// This function returns zero-copy data where possible - strings are borrowed +/// from the input buffer. Use `.into_owned()` to convert to an owned `Vdf<'static>`. +pub fn parse_shortcuts(input: &[u8]) -> Result<Vdf<'_>> { + binary::parse_shortcuts(input) +} + +/// Parse an appinfo.vdf format binary file. +/// +/// This function returns zero-copy data where possible - strings are borrowed +/// from the input buffer. Use `.into_owned()` to convert to an owned `Vdf<'static>`. +pub fn parse_appinfo(input: &[u8]) -> Result<Vdf<'_>> { + binary::parse_appinfo(input) +} + +/// Parse a packageinfo.vdf format binary file. +/// +/// This function returns zero-copy data where possible - strings are borrowed +/// from the input buffer. Use `.into_owned()` to convert to an owned `Vdf<'static>`. +pub fn parse_packageinfo(input: &[u8]) -> Result<Vdf<'_>> { + binary::parse_packageinfo(input) +} + +// Convert from borrowed to owned +impl Vdf<'_> { + /// Convert to an owned version (with 'static lifetime). + /// + /// This creates a new `Vdf<'static>` with all strings owned, allowing the + /// data to outlive the original input. + pub fn into_owned(self) -> Vdf<'static> { + let (key, value) = self.into_parts(); + let owned_key: Cow<'static, str> = match key { + Cow::Borrowed(s) => Cow::Owned(s.to_string()), + Cow::Owned(s) => Cow::Owned(s), + }; + Vdf::new(owned_key, value.into_owned()) + } +} + +impl Value<'_> { + /// Convert to an owned version (with 'static lifetime). + pub fn into_owned(self) -> Value<'static> { + match self { + Value::Str(s) => Value::Str(match s { + Cow::Borrowed(b) => b.to_string().into(), + Cow::Owned(o) => o.into(), + }), + Value::Obj(obj) => Value::Obj(obj.into_owned()), + Value::I32(n) => Value::I32(n), + Value::U64(n) => Value::U64(n), + Value::Float(n) => Value::Float(n), + Value::Pointer(n) => Value::Pointer(n), + Value::Color(c) => Value::Color(c), + } + } +} + +impl Obj<'_> { + /// Convert to an owned version (with 'static lifetime). + pub fn into_owned(self) -> Obj<'static> { + let mut new = Obj::new(); + for (k, v) in self.iter() { + let owned_key: Cow<'static, str> = match k { + Cow::Borrowed(b) => Cow::Owned(b.to_string()), + Cow::Owned(o) => Cow::Owned(o.clone()), + }; + // Clone the value since we're iterating + new.insert(owned_key, v.clone().into_owned()); + } + new + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + // Simple shortcuts.vdf format test data (object start, key, string value, object end) + const SHORTCUTS_VDF: &[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, // Nested key "key" + b'v', b'a', b'l', b'u', b'e', 0x00, // Value "value" + 0x08, // Object end + ]; + + #[test] + fn test_parse_binary() { + let vdf = parse_binary(SHORTCUTS_VDF).unwrap(); + assert!(vdf.as_obj().is_some()); + assert_eq!(vdf.key(), "root"); + let obj = vdf.as_obj().unwrap(); + let test_obj = obj.get("test").and_then(|v| v.as_obj()).unwrap(); + assert_eq!(test_obj.get("key").and_then(|v| v.as_str()), Some("value")); + } + + #[test] + fn test_parse_shortcuts() { + let vdf = parse_shortcuts(SHORTCUTS_VDF).unwrap(); + assert!(vdf.as_obj().is_some()); + assert_eq!(vdf.key(), "root"); + let obj = vdf.as_obj().unwrap(); + let test_obj = obj.get("test").and_then(|v| v.as_obj()).unwrap(); + assert_eq!(test_obj.get("key").and_then(|v| v.as_str()), Some("value")); + } + + #[test] + fn test_parse_appinfo() { + // appinfo v40 magic + terminator (no apps) + // Need 8 bytes (magic + universe) + 68 bytes (APPINFO_ENTRY_HEADER_SIZE) = 76 bytes total + let mut input = vec![ + 0x28, 0x44, 0x56, 0x07, // magic: 0x07564428 (APPINFO_MAGIC_40) + 0x20, 0x00, 0x00, 0x00, // universe: 32 + 0x00, 0x00, 0x00, 0x00, // app_id = 0 (terminator) + ]; + // Pad to 76 bytes total (8 + APPINFO_ENTRY_HEADER_SIZE) + input.resize(76, 0); + let result = parse_appinfo(&input); + if let Err(e) = &result { + panic!("parse_appinfo failed: {:?}", e); + } + assert!(result.is_ok()); + let vdf = result.unwrap(); + assert!(vdf.key().starts_with("appinfo_universe_")); + } + + #[test] + fn test_into_owned_vdf() { + let input = r#""root" + { + "key" "value" + }"#; + let borrowed = parse_text(input).unwrap(); + let owned = borrowed.into_owned(); + assert_eq!(owned.key(), "root"); + } + + #[test] + fn test_into_owned_value_str() { + let borrowed = Value::Str("test".into()); + let owned = borrowed.into_owned(); + assert!(matches!(owned, Value::Str(Cow::Owned(_)))); + } + + #[test] + fn test_into_owned_value_obj() { + let mut obj = Obj::new(); + obj.insert("key", Value::Str("value".into())); + let borrowed = Value::Obj(obj); + let owned = borrowed.into_owned(); + assert!(matches!(owned, Value::Obj(_))); + } + + #[test] + fn test_into_owned_obj() { + let mut obj = Obj::new(); + obj.insert("key", Value::Str("value".into())); + let owned = obj.into_owned(); + assert!(owned.get("key").is_some()); + } +} diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/text/mod.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/text/mod.rs new file mode 100644 index 0000000..2e091a5 --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/text/mod.rs @@ -0,0 +1,7 @@ +//! Text VDF format parser. +//! +//! Parses human-readable VDF text format using winnow parser combinators. + +pub mod parser; + +pub use parser::parse as parse_text; diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/text/parser.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/text/parser.rs new file mode 100644 index 0000000..730a8bb --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/text/parser.rs @@ -0,0 +1,408 @@ +//! Text VDF parser powered by winnow. + +use alloc::borrow::Cow; +use alloc::string::String; + +use winnow::ascii::{line_ending, multispace1}; +use winnow::combinator::{alt, delimited, preceded, repeat}; +use winnow::error::{ContextError, StrContext}; +use winnow::prelude::*; +use winnow::token::{one_of, take_till}; + +use crate::error::{Result, parse_error}; +use crate::value::{Obj, Value, Vdf}; + +/// Parse a VDF document from text format. +/// +/// # Example +/// +/// ``` +/// use steam_vdf_parser::parse_text; +/// +/// let input = r#""root" +/// { +/// "key" "value" +/// }"#; +/// let vdf = parse_text(input).unwrap(); +/// assert_eq!(vdf.key(), "root"); +/// ``` +pub fn parse(input: &str) -> Result<Vdf<'_>> { + let mut input = input.trim_start(); + + let key = token + .parse_next(&mut input) + .map_err(|_| parse_error(input, 0, "expected root key"))?; + + let obj = object + .parse_next(&mut input) + .map_err(|_| parse_error(input, 0, "expected root object"))?; + + Ok(Vdf::new(Cow::Borrowed(key), Value::Obj(obj))) +} + +/// Parse a token (either quoted or unquoted). +fn token<'i>(input: &mut &'i str) -> ModalResult<&'i str> { + preceded(whitespace, alt((quoted_string, unquoted_string))).parse_next(input) +} + +/// Parse a quoted string, returning a Cow (borrowed if no escapes, owned if escapes processed). +/// +/// Handles escape sequences: \n, \t, \r, \\, \" +fn quoted_string_cow<'i>(input: &mut &'i str) -> ModalResult<Cow<'i, str>> { + // Parse opening quote + '"'.parse_next(input)?; + + // Check if there are any escape sequences + let content_end = input.find(['\\', '"']).unwrap_or(input.len()); + + if content_end < input.len() && input[content_end..].starts_with('\\') { + // Has escape sequences - need to process them + let mut result = String::from(&input[..content_end]); + *input = &input[content_end..]; + + loop { + // Check for closing quote + if let Some(c) = input.chars().next() { + if c == '"' { + *input = &input[c.len_utf8()..]; + return Ok(Cow::Owned(result)); + } + if c == '\\' { + // Escape sequence - consume backslash + *input = &input[c.len_utf8()..]; + + // Get escaped character + let escaped = one_of(('n', 't', 'r', '\\', '"')) + .map(|c| match c { + 'n' => '\n', + 't' => '\t', + 'r' => '\r', + '\\' => '\\', + '"' => '"', + _ => unreachable!(), + }) + .parse_next(input)?; + result.push(escaped); + } else { + result.push(c); + *input = &input[c.len_utf8()..]; + } + } else { + // EOF before closing quote - fail + return Err(winnow::error::ErrMode::Backtrack(ContextError::new())); + } + } + } else { + // No escapes - zero copy path + let content = &input[..content_end]; + *input = &input[content_end..]; + + // Parse closing quote + '"'.parse_next(input)?; + + Ok(Cow::Borrowed(content)) + } +} + +/// Parse a quoted string (borrowed version for key parsing). +/// Keys with escapes will fail - use quoted_string_cow for values that may have escapes. +fn quoted_string<'i>(input: &mut &'i str) -> ModalResult<&'i str> { + '"'.parse_next(input)?; + + // Find closing quote, checking for escapes + let mut end = 0; + let mut chars = input.char_indices(); + while let Some((idx, c)) = chars.next() { + if c == '"' { + end = idx; + break; + } + if c == '\\' { + // Skip escaped character + chars.next(); + } + } + + if end == 0 { + return Err(winnow::error::ErrMode::Backtrack(ContextError::new())); + } + + let result = &input[..end]; + *input = &input[end + '"'.len_utf8()..]; + + Ok(result) +} + +/// Parse an unquoted string. +/// +/// Unquoted strings end at whitespace, `{`, `}`, or `"`. +fn unquoted_string<'i>(input: &mut &'i str) -> ModalResult<&'i str> { + take_till(1.., |c: char| { + c.is_whitespace() || c == '{' || c == '}' || c == '"' + }) + .context(StrContext::Label("token")) + .parse_next(input) +} + +/// Parse an object (recursive block of key-value pairs). +fn object<'i>(input: &mut &'i str) -> ModalResult<Obj<'i>> { + preceded( + whitespace, + delimited('{', object_body, preceded(whitespace, '}')), + ) + .context(StrContext::Label("object")) + .parse_next(input) +} + +/// Parse the body of an object (key-value pairs until closing brace). +fn object_body<'i>(input: &mut &'i str) -> ModalResult<Obj<'i>> { + let mut obj = Obj::new(); + + loop { + // Skip whitespace + whitespace.parse_next(input)?; + + // Check for closing brace + if input.starts_with('}') { + break; + } + + // Parse a key-value pair + let (key, value) = kv_pair.parse_next(input)?; + obj.insert(Cow::Borrowed(key), value); + } + + Ok(obj) +} + +/// Parse a key-value pair. +fn kv_pair<'i>(input: &mut &'i str) -> ModalResult<(&'i str, Value<'i>)> { + let key = token.parse_next(input)?; + + // Skip whitespace before value + whitespace.parse_next(input)?; + + // Parse the value + let value = if let Some(c) = input.chars().next() { + match c { + '{' => object.map(Value::Obj).parse_next(input)?, + '"' => quoted_string_cow.map(Value::Str).parse_next(input)?, + _ => unquoted_string + .map(|s| Value::Str(Cow::Borrowed(s))) + .parse_next(input)?, + } + } else { + return Err(winnow::error::ErrMode::Backtrack(ContextError::new())); + }; + + Ok((key, value)) +} + +/// Skip whitespace and line comments. +fn whitespace(input: &mut &str) -> ModalResult<()> { + repeat(0.., alt((multispace1.void(), line_comment.void()))).parse_next(input) +} + +/// Parse a line comment (// to newline). +fn line_comment(input: &mut &str) -> ModalResult<()> { + preceded( + "//", + alt((line_ending.void(), take_till(0.., ['\r', '\n']).void())), + ) + .parse_next(input) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::format; + + #[test] + fn test_parse_simple_kv() { + let input = r#""root" + { + "key" "value" + }"#; + let vdf = parse(input).unwrap(); + assert_eq!(vdf.key(), "root"); + + let obj = vdf.as_obj().unwrap(); + let value = obj.get("key").and_then(|v| v.as_str()); + assert_eq!(value, Some("value")); + } + + #[test] + fn test_parse_nested_objects() { + let input = r#""outer" + { + "inner" + { + "key" "value" + } + }"#; + let vdf = parse(input).unwrap(); + assert_eq!(vdf.key(), "outer"); + + let obj = vdf.as_obj().unwrap(); + let inner = obj.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_unquoted_tokens() { + let input = r#"root + { + key value + }"#; + let vdf = parse(input).unwrap(); + assert_eq!(vdf.key(), "root"); + + let obj = vdf.as_obj().unwrap(); + let value = obj.get("key").and_then(|v| v.as_str()); + assert_eq!(value, Some("value")); + } + + #[test] + fn test_parse_with_comments() { + let input = r#""root" + { + // This is a comment + "key" "value" + // Another comment + }"#; + let vdf = parse(input).unwrap(); + + let obj = vdf.as_obj().unwrap(); + let value = obj.get("key").and_then(|v| v.as_str()); + assert_eq!(value, Some("value")); + } + + #[test] + fn test_parse_multiple_keys() { + let input = r#""settings" + { + "name" "test" + "count" "42" + }"#; + let vdf = parse(input).unwrap(); + + let obj = vdf.as_obj().unwrap(); + assert_eq!(obj.get("name").and_then(|v| v.as_str()), Some("test")); + assert_eq!(obj.get("count").and_then(|v| v.as_str()), Some("42")); + } + + #[test] + fn test_escape_sequences() { + let test_cases: &[(&str, &str)] = &[ + (r#""test\nline""#, "test\nline"), + (r#""test\ttab""#, "test\ttab"), + (r#""test\\backslash""#, "test\\backslash"), + (r#""test\"quote""#, "test\"quote"), + (r#""test\rreturn""#, "test\rreturn"), + ]; + + for (input, expected) in test_cases { + let full_input = format!(r#""root"{{"key" {}}}"#, input); + let vdf = parse(&full_input).unwrap(); + let obj = vdf.as_obj().unwrap(); + let value = obj.get("key").and_then(|v| v.as_str()).unwrap(); + assert_eq!(value, *expected, "Failed for input: {}", input); + } + } + + #[test] + fn test_escape_sequences_in_nested_objects() { + let input = r#""root" + { + "outer" + { + "key" "value\nwith\nnewlines" + } + }"#; + let vdf = parse(input).unwrap(); + let outer = vdf + .as_obj() + .unwrap() + .get("outer") + .and_then(|v| v.as_obj()) + .unwrap(); + let value = outer.get("key").and_then(|v| v.as_str()).unwrap(); + assert_eq!(value, "value\nwith\nnewlines"); + } + + #[test] + fn test_mixed_escape_sequences() { + let input = r#""root"{"key" "line1\nline2\ttab\\slash\"quote"}"#; + let vdf = parse(input).unwrap(); + let obj = vdf.as_obj().unwrap(); + let value = obj.get("key").and_then(|v| v.as_str()).unwrap(); + assert_eq!(value, "line1\nline2\ttab\\slash\"quote"); + } + + #[test] + fn test_unquoted_token_no_escape_processing() { + let input = r#"root{key value\nnotescaped}"#; + let vdf = parse(input).unwrap(); + let obj = vdf.as_obj().unwrap(); + let value = obj.get("key").and_then(|v| v.as_str()).unwrap(); + // Unquoted tokens should have literal backslash-n + assert_eq!(value, r#"value\nnotescaped"#); + } + + #[test] + fn test_quoted_string_without_escapes_zero_copy() { + let input = r#""root"{"key" "value"}"#; + let vdf = parse(input).unwrap(); + let obj = vdf.as_obj().unwrap(); + let value = obj.get("key").and_then(|v| v.as_str()).unwrap(); + // Without escapes, value should be parsed correctly (zero-copy internally) + assert_eq!(value, "value"); + } + + #[test] + fn test_quoted_string_with_escapes_owned() { + let input = r#""root"{"key" "value\nwith\nescape"}"#; + let vdf = parse(input).unwrap(); + let obj = vdf.as_obj().unwrap(); + let value = obj.get("key").and_then(|v| v.as_str()).unwrap(); + // With escapes, value should be parsed correctly (owned internally) + assert_eq!(value, "value\nwith\nescape"); + } + + #[test] + fn test_empty_object() { + let input = r#""root"{}"#; + let vdf = parse(input).unwrap(); + let obj = vdf.as_obj().unwrap(); + assert!(obj.is_empty()); + } + + #[test] + fn test_deeply_nested_objects() { + let input = r#""root" + { + "level1" + { + "level2" + { + "level3" + { + "key" "value" + } + } + } + }"#; + let vdf = parse(input).unwrap(); + let level1 = vdf + .as_obj() + .unwrap() + .get("level1") + .and_then(|v| v.as_obj()) + .unwrap(); + let level2 = level1.get("level2").and_then(|v| v.as_obj()).unwrap(); + let level3 = level2.get("level3").and_then(|v| v.as_obj()).unwrap(); + let value = level3.get("key").and_then(|v| v.as_str()).unwrap(); + assert_eq!(value, "value"); + } +} diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/value/hasher.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/value/hasher.rs new file mode 100644 index 0000000..126ea86 --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/value/hasher.rs @@ -0,0 +1,127 @@ +//! Default hasher for IndexMap. + +use core::fmt::Debug; +use core::hash::{BuildHasher, Hasher}; +use foldhash::fast::RandomState; +use static_assertions::assert_impl_all; + +/// Default hash builder for IndexMap. +#[derive(Clone, Debug, Default)] +pub struct DefaultHashBuilder { + inner: RandomState, +} + +impl BuildHasher for DefaultHashBuilder { + type Hasher = DefaultHasher; + + #[inline(always)] + fn build_hasher(&self) -> Self::Hasher { + DefaultHasher { + inner: self.inner.build_hasher(), + } + } +} + +/// Default hasher. +#[derive(Clone)] +pub struct DefaultHasher { + inner: <RandomState as BuildHasher>::Hasher, +} + +impl Hasher for DefaultHasher { + #[inline(always)] + fn write(&mut self, bytes: &[u8]) { + self.inner.write(bytes); + } + + #[inline(always)] + fn write_u8(&mut self, i: u8) { + self.inner.write_u8(i); + } + + #[inline(always)] + fn write_u16(&mut self, i: u16) { + self.inner.write_u16(i); + } + + #[inline(always)] + fn write_u32(&mut self, i: u32) { + self.inner.write_u32(i); + } + + #[inline(always)] + fn write_u64(&mut self, i: u64) { + self.inner.write_u64(i); + } + + #[inline(always)] + fn write_u128(&mut self, i: u128) { + self.inner.write_u128(i); + } + + #[inline(always)] + fn write_usize(&mut self, i: usize) { + self.inner.write_usize(i); + } + + #[inline(always)] + fn write_i8(&mut self, i: i8) { + self.inner.write_i8(i); + } + + #[inline(always)] + fn write_i16(&mut self, i: i16) { + self.inner.write_i16(i); + } + + #[inline(always)] + fn write_i32(&mut self, i: i32) { + self.inner.write_i32(i); + } + + #[inline(always)] + fn write_i64(&mut self, i: i64) { + self.inner.write_i64(i); + } + + #[inline(always)] + fn write_i128(&mut self, i: i128) { + self.inner.write_i128(i); + } + + #[inline(always)] + fn write_isize(&mut self, i: isize) { + self.inner.write_isize(i); + } + + #[inline(always)] + fn finish(&self) -> u64 { + self.inner.finish() + } +} + +assert_impl_all!(DefaultHashBuilder: Clone, Debug, Default, BuildHasher); +assert_impl_all!(DefaultHasher: Clone, Hasher); + +#[cfg(test)] +mod tests { + use super::*; + use core::hash::{BuildHasher, Hasher}; + + #[test] + fn write_order_affects_hash() { + let builder = DefaultHashBuilder::default(); + let hasher = builder.build_hasher(); + + let mut h1 = hasher.clone(); + let mut h2 = hasher.clone(); + + h1.write(b"a"); + h1.write(b"b"); + + h2.write(b"b"); + h2.write(b"a"); + + assert_ne!(h1.finish(), h2.finish()); + } +} diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/value/impls.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/value/impls.rs new file mode 100644 index 0000000..422c6f7 --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/value/impls.rs @@ -0,0 +1,222 @@ +//! Trait implementations for VDF types. + +use alloc::borrow::Cow; +use alloc::string::String; + +use super::types::{Key, Obj, Value, Vdf}; +use core::fmt; +use core::fmt::Write as _; +use core::ops::Index; + +// ============================================================================ +// Pretty-print helper functions +// ============================================================================ + +/// Write a quoted and escaped string to the formatter. +/// +/// Escapes special characters: `\n`, `\t`, `\r`, `\\`, `"` +fn write_quoted_str(f: &mut fmt::Formatter<'_>, s: &str) -> fmt::Result { + f.write_char('"')?; + for c in s.chars() { + match c { + '\n' => f.write_str("\\n")?, + '\t' => f.write_str("\\t")?, + '\r' => f.write_str("\\r")?, + '\\' => f.write_str("\\\\")?, + '"' => f.write_str("\\\"")?, + c => f.write_char(c)?, + } + } + f.write_char('"') +} + +/// Write indentation (tabs) to the formatter. +fn write_indent(f: &mut fmt::Formatter<'_>, level: usize) -> fmt::Result { + for _ in 0..level { + f.write_char('\t')?; + } + Ok(()) +} + +/// Helper struct for pretty-printing an Obj with a specific indent level. +struct PrettyObj<'a, 'text> { + obj: &'a Obj<'text>, + indent: usize, +} + +impl fmt::Display for PrettyObj<'_, '_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{{")?; + for (key, value) in self.obj.inner.iter() { + write_indent(f, self.indent + 1)?; + write_quoted_str(f, key)?; + if let Value::Obj(inner_obj) = value { + // Object value: key on one line, value (with braces) on next lines + writeln!(f)?; + write_indent(f, self.indent + 1)?; + write!( + f, + "{}", + PrettyObj { + obj: inner_obj, + indent: self.indent + 1 + } + )?; + writeln!(f)?; + } else { + // Scalar value: key<tab>value on same line + write!(f, "\t")?; + write!(f, "{:#}", value)?; + writeln!(f)?; + } + } + write_indent(f, self.indent)?; + write!(f, "}}") + } +} + +// ============================================================================ +// From implementations for Value +// ============================================================================ + +impl<'text> From<&'text str> for Value<'text> { + fn from(s: &'text str) -> Self { + Value::Str(Cow::Borrowed(s)) + } +} + +impl From<String> for Value<'static> { + fn from(s: String) -> Self { + Value::Str(Cow::Owned(s)) + } +} + +impl From<i32> for Value<'static> { + fn from(n: i32) -> Self { + Value::I32(n) + } +} + +impl From<u64> for Value<'static> { + fn from(n: u64) -> Self { + Value::U64(n) + } +} + +impl From<f32> for Value<'static> { + fn from(n: f32) -> Self { + Value::Float(n) + } +} + +impl From<u32> for Value<'static> { + fn from(n: u32) -> Self { + Value::Pointer(n) + } +} + +impl From<[u8; 4]> for Value<'static> { + fn from(color: [u8; 4]) -> Self { + Value::Color(color) + } +} + +impl<'text> From<Obj<'text>> for Value<'text> { + fn from(obj: Obj<'text>) -> Self { + Value::Obj(obj) + } +} + +impl<'text> fmt::Display for Value<'text> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Value::Str(s) => write_quoted_str(f, s), + Value::Obj(obj) => write!(f, "{}", obj), + Value::I32(n) => write!(f, "\"{}\"", n), + Value::U64(n) => write!(f, "\"{}\"", n), + Value::Float(n) => write!(f, "\"{}\"", n), + Value::Pointer(n) => write!(f, "\"0x{:08x}\"", n), + Value::Color(c) => write!(f, "\"{} {} {} {}\"", c[0], c[1], c[2], c[3]), + } + } +} + +impl<'text> Default for Obj<'text> { + fn default() -> Self { + Self::new() + } +} + +impl<'text> fmt::Display for Obj<'text> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + PrettyObj { + obj: self, + indent: 0 + } + ) + } +} + +impl<'text> fmt::Display for Vdf<'text> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write_quoted_str(f, self.key())?; + writeln!(f)?; + write!(f, "{}", self.value()) + } +} + +// ============================================================================ +// IntoIterator implementations for Obj +// ============================================================================ + +impl<'text> IntoIterator for Obj<'text> { + type Item = (Key<'text>, Value<'text>); + type IntoIter = indexmap::map::IntoIter<Key<'text>, Value<'text>>; + + fn into_iter(self) -> Self::IntoIter { + self.inner.into_iter() + } +} + +impl<'a, 'text> IntoIterator for &'a Obj<'text> { + type Item = (&'a Key<'text>, &'a Value<'text>); + type IntoIter = indexmap::map::Iter<'a, Key<'text>, Value<'text>>; + + fn into_iter(self) -> Self::IntoIter { + self.inner.iter() + } +} + +// ============================================================================ +// Index implementations for Value and Obj +// ============================================================================ + +impl<'text> Index<&str> for Value<'text> { + type Output = Value<'text>; + + /// Returns a reference to the value at the given key. + /// + /// # Panics + /// + /// Panics if this is not an object or if the key doesn't exist. + /// Use `get()` for non-panicking access. + fn index(&self, key: &str) -> &Self::Output { + self.get(key).expect("key not found in Value") + } +} + +impl<'text> Index<&str> for Obj<'text> { + type Output = Value<'text>; + + /// Returns a reference to the value at the given key. + /// + /// # Panics + /// + /// Panics if the key doesn't exist. Use `get()` for non-panicking access. + fn index(&self, key: &str) -> &Self::Output { + self.get(key).expect("key not found in Obj") + } +} diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/value/mod.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/value/mod.rs new file mode 100644 index 0000000..2518957 --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/value/mod.rs @@ -0,0 +1,10 @@ +//! Core data structures for VDF representation. + +mod hasher; +mod impls; +mod types; + +#[cfg(test)] +mod tests; + +pub use types::{Obj, Value, Vdf}; diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/value/tests.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/value/tests.rs new file mode 100644 index 0000000..5dda1e9 --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/value/tests.rs @@ -0,0 +1,802 @@ +//! Unit tests for VDF types. + +use super::types::{Obj, Value, Vdf}; +use alloc::borrow::Cow; +use alloc::format; +use alloc::string::{String, ToString}; + +// ============================================================================ +// From implementation tests +// ============================================================================ + +#[test] +fn test_value_from_str() { + let value: Value = "hello".into(); + assert!(value.is_str()); + assert_eq!(value.as_str(), Some("hello")); +} + +#[test] +fn test_value_from_string() { + let value: Value = String::from("hello").into(); + assert!(value.is_str()); + assert_eq!(value.as_str(), Some("hello")); +} + +#[test] +fn test_value_from_i32() { + let value: Value = 42i32.into(); + assert!(value.is_i32()); + assert_eq!(value.as_i32(), Some(42)); +} + +#[test] +fn test_value_from_u64() { + let value: Value = 123u64.into(); + assert!(value.is_u64()); + assert_eq!(value.as_u64(), Some(123)); +} + +#[test] +fn test_value_from_f32() { + let value: Value = 2.5f32.into(); + assert!(value.is_float()); + assert_eq!(value.as_float(), Some(2.5)); +} + +#[test] +fn test_value_from_u32() { + let value: Value = 0x12345678u32.into(); + assert!(value.is_pointer()); + assert_eq!(value.as_pointer(), Some(0x12345678)); +} + +#[test] +fn test_value_from_color() { + let value: Value = [255u8, 0, 128, 64].into(); + assert!(value.is_color()); + assert_eq!(value.as_color(), Some([255, 0, 128, 64])); +} + +#[test] +fn test_value_from_obj() { + let obj = Obj::new(); + let value: Value = obj.into(); + assert!(value.is_obj()); +} + +// ============================================================================ +// Type check tests +// ============================================================================ + +#[test] +fn test_value_is_methods() { + let v = Value::I32(42); + assert!(v.is_i32()); + assert!(!v.is_str()); + assert!(!v.is_obj()); +} + +#[test] +fn test_value_is_str() { + let v = Value::Str(Cow::Borrowed("test")); + assert!(v.is_str()); + assert!(!v.is_i32()); +} + +#[test] +fn test_value_is_obj() { + let v = Value::Obj(Obj::new()); + assert!(v.is_obj()); + assert!(!v.is_str()); +} + +#[test] +fn test_value_is_float() { + let v = Value::Float(1.0); + assert!(v.is_float()); + assert!(!v.is_i32()); +} + +#[test] +fn test_value_is_u64() { + let v = Value::U64(100); + assert!(v.is_u64()); + assert!(!v.is_i32()); +} + +#[test] +fn test_value_is_pointer() { + let v = Value::Pointer(0x12345678); + assert!(v.is_pointer()); + assert!(!v.is_i32()); +} + +#[test] +fn test_value_is_color() { + let v = Value::Color([255, 0, 0, 255]); + assert!(v.is_color()); + assert!(!v.is_i32()); +} + +#[test] +fn test_value_as_methods() { + let v = Value::I32(42); + assert_eq!(v.as_i32(), Some(42)); + assert_eq!(v.as_str(), None); + assert_eq!(v.as_obj(), None); +} + +#[test] +fn test_value_as_str() { + let v = Value::Str(Cow::Borrowed("test")); + assert_eq!(v.as_str(), Some("test")); +} + +#[test] +fn test_value_as_obj() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("key"), Value::I32(42)); + let v = Value::Obj(obj); + assert!(v.as_obj().is_some()); +} + +#[test] +fn test_value_as_float() { + let v = Value::Float(1.5); + assert_eq!(v.as_float(), Some(1.5)); +} + +#[test] +fn test_value_as_u64() { + let v = Value::U64(123456789); + assert_eq!(v.as_u64(), Some(123456789)); +} + +#[test] +fn test_value_as_pointer() { + let v = Value::Pointer(0xABCDEF01); + assert_eq!(v.as_pointer(), Some(0xABCDEF01)); +} + +#[test] +fn test_value_as_color() { + let v = Value::Color([255, 128, 64, 32]); + assert_eq!(v.as_color(), Some([255, 128, 64, 32])); +} + +#[test] +fn test_value_display_i32() { + assert_eq!(format!("{}", Value::I32(42)), "\"42\""); + assert_eq!(format!("{}", Value::I32(-42)), "\"-42\""); +} + +#[test] +fn test_value_display_u64() { + assert_eq!(format!("{}", Value::U64(100)), "\"100\""); +} + +#[test] +fn test_value_display_str() { + assert_eq!(format!("{}", Value::Str(Cow::Borrowed("test"))), "\"test\""); +} + +#[test] +fn test_value_display_obj() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("key"), Value::I32(42)); + let v = Value::Obj(obj); + assert!(format!("{}", v).contains("\"key\"")); + assert!(format!("{}", v).contains("\"42\"")); +} + +#[test] +fn test_value_display_float() { + let v = Value::Float(1.5); + assert_eq!(format!("{}", v), "\"1.5\""); +} + +#[test] +fn test_value_display_pointer() { + assert_eq!(format!("{}", Value::Pointer(0x12345678)), "\"0x12345678\""); +} + +#[test] +fn test_value_display_color() { + assert_eq!( + format!("{}", Value::Color([255, 0, 0, 255])), + "\"255 0 0 255\"" + ); +} + +#[test] +fn test_obj_new_is_empty() { + let obj = Obj::new(); + assert!(obj.is_empty()); + assert_eq!(obj.len(), 0); +} + +#[test] +fn test_obj_get() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("key"), Value::I32(42)); + assert_eq!(obj.get("key").and_then(|v| v.as_i32()), Some(42)); + assert_eq!(obj.get("missing"), None); +} + +#[test] +fn test_obj_len() { + let mut obj = Obj::new(); + assert_eq!(obj.len(), 0); + obj.insert(Cow::Borrowed("key1"), Value::I32(1)); + assert_eq!(obj.len(), 1); + obj.insert(Cow::Borrowed("key2"), Value::I32(2)); + assert_eq!(obj.len(), 2); +} + +#[test] +fn test_obj_iter() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("key1"), Value::I32(1)); + obj.insert(Cow::Borrowed("key2"), Value::I32(2)); + let mut iter = obj.iter(); + assert!(iter.next().is_some()); + assert!(iter.next().is_some()); + assert!(iter.next().is_none()); +} + +#[test] +fn test_obj_default() { + let obj = Obj::default(); + assert!(obj.is_empty()); +} + +#[test] +fn test_vdf_new() { + let vdf = Vdf::new("root", Value::Obj(Obj::new())); + assert_eq!(vdf.key(), "root"); + assert!(vdf.is_obj()); +} + +#[test] +fn test_vdf_is_obj() { + let vdf = Vdf::new(Cow::Borrowed("root"), Value::Obj(Obj::new())); + assert!(vdf.is_obj()); + let vdf2 = Vdf::new(Cow::Borrowed("root"), Value::I32(42)); + assert!(!vdf2.is_obj()); +} + +#[test] +fn test_vdf_as_obj() { + let vdf = Vdf::new(Cow::Borrowed("root"), Value::Obj(Obj::new())); + assert!(vdf.as_obj().is_some()); + let vdf2 = Vdf::new(Cow::Borrowed("root"), Value::I32(42)); + assert!(vdf2.as_obj().is_none()); +} + +#[test] +fn test_vdf_display() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("key"), Value::I32(42)); + let vdf = Vdf::new(Cow::Borrowed("root"), Value::Obj(obj)); + let s = format!("{}", vdf); + assert!(s.contains("root")); +} + +#[test] +fn test_into_owned_value_str() { + let value = Value::Str(Cow::Borrowed("test")); + let owned = value.into_owned(); + assert_eq!(owned, Value::Str(Cow::Owned("test".to_string()))); +} + +#[test] +fn test_into_owned_value_str_already_owned() { + let value = Value::Str(Cow::Owned("test".to_string())); + let owned = value.into_owned(); + assert_eq!(owned, Value::Str(Cow::Owned("test".to_string()))); +} + +#[test] +fn test_into_owned_value_obj() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("key"), Value::I32(42)); + let value = Value::Obj(obj); + let owned = value.into_owned(); + assert!(owned.is_obj()); +} + +#[test] +fn test_into_owned_value_numeric() { + assert_eq!(Value::I32(42).into_owned(), Value::I32(42)); + assert_eq!(Value::U64(100).into_owned(), Value::U64(100)); + assert_eq!(Value::Float(1.5).into_owned(), Value::Float(1.5)); + assert_eq!(Value::Pointer(123).into_owned(), Value::Pointer(123)); + assert_eq!( + Value::Color([1, 2, 3, 4]).into_owned(), + Value::Color([1, 2, 3, 4]) + ); +} + +#[test] +fn test_into_owned_obj() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("key"), Value::I32(42)); + let owned = obj.into_owned(); + assert_eq!(owned.len(), 1); + assert_eq!(owned.get("key").and_then(|v| v.as_i32()), Some(42)); +} + +#[test] +fn test_into_owned_obj_nested() { + let mut inner = Obj::new(); + inner.insert( + Cow::Borrowed("inner_key"), + Value::Str(Cow::Borrowed("value")), + ); + let mut outer = Obj::new(); + outer.insert(Cow::Borrowed("outer_key"), Value::Obj(inner)); + let owned = outer.into_owned(); + let inner_obj = owned.get("outer_key").and_then(|v| v.as_obj()).unwrap(); + assert_eq!( + inner_obj.get("inner_key").and_then(|v| v.as_str()), + Some("value") + ); +} + +#[test] +fn test_into_owned_vdf() { + let vdf = Vdf::new("root", Value::I32(42)); + let owned = vdf.into_owned(); + assert_eq!(owned.key(), "root"); + assert_eq!(owned.value(), &Value::I32(42)); +} + +// Tests for Value::get and get_path methods + +#[test] +fn test_value_get_on_obj() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("key"), Value::I32(42)); + let value = Value::Obj(obj); + assert_eq!(value.get("key").and_then(|v| v.as_i32()), Some(42)); + assert_eq!(value.get("missing"), None); +} + +#[test] +fn test_value_get_on_non_obj() { + let value = Value::I32(42); + assert_eq!(value.get("key"), None); +} + +#[test] +fn test_value_get_path_single_key() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("key"), Value::I32(42)); + let value = Value::Obj(obj); + assert_eq!(value.get_path(&["key"]).and_then(|v| v.as_i32()), Some(42)); +} + +#[test] +fn test_value_get_path_empty() { + let value = Value::I32(42); + assert_eq!(value.get_path(&[]).and_then(|v| v.as_i32()), Some(42)); +} + +#[test] +fn test_value_get_path_nested() { + let mut inner = Obj::new(); + inner.insert(Cow::Borrowed("c"), Value::Str(Cow::Borrowed("found"))); + let mut middle = Obj::new(); + middle.insert(Cow::Borrowed("b"), Value::Obj(inner)); + let mut outer = Obj::new(); + outer.insert(Cow::Borrowed("a"), Value::Obj(middle)); + let value = Value::Obj(outer); + + assert_eq!( + value.get_path(&["a", "b", "c"]).and_then(|v| v.as_str()), + Some("found") + ); +} + +#[test] +fn test_value_get_path_missing_intermediate() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("a"), Value::I32(42)); + let value = Value::Obj(obj); + assert_eq!(value.get_path(&["a", "b"]), None); +} + +#[test] +fn test_value_get_path_non_obj_intermediate() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("a"), Value::I32(42)); + let value = Value::Obj(obj); + assert_eq!(value.get_path(&["a", "b"]), None); +} + +#[test] +fn test_value_get_str() { + let mut inner = Obj::new(); + inner.insert(Cow::Borrowed("name"), Value::Str(Cow::Borrowed("test"))); + let mut outer = Obj::new(); + outer.insert(Cow::Borrowed("data"), Value::Obj(inner)); + let value = Value::Obj(outer); + + assert_eq!(value.get_str(&["data", "name"]), Some("test")); + assert_eq!(value.get_str(&["data", "missing"]), None); +} + +#[test] +fn test_value_get_obj() { + let mut inner = Obj::new(); + inner.insert(Cow::Borrowed("key"), Value::I32(1)); + let mut outer = Obj::new(); + outer.insert(Cow::Borrowed("nested"), Value::Obj(inner)); + let value = Value::Obj(outer); + + let obj = value.get_obj(&["nested"]).unwrap(); + assert_eq!(obj.len(), 1); +} + +#[test] +fn test_value_get_i32() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("num"), Value::I32(123)); + let value = Value::Obj(obj); + + assert_eq!(value.get_i32(&["num"]), Some(123)); + assert_eq!(value.get_i32(&["missing"]), None); +} + +#[test] +fn test_value_get_u64() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("big"), Value::U64(9999999999)); + let value = Value::Obj(obj); + + assert_eq!(value.get_u64(&["big"]), Some(9999999999)); +} + +#[test] +fn test_value_get_float() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("ratio"), Value::Float(2.5)); + let value = Value::Obj(obj); + + assert_eq!(value.get_float(&["ratio"]), Some(2.5)); +} + +// Tests for Vdf delegation methods + +#[test] +fn test_vdf_get() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("key"), Value::I32(42)); + let vdf = Vdf::new(Cow::Borrowed("root"), Value::Obj(obj)); + + assert_eq!(vdf.get("key").and_then(|v| v.as_i32()), Some(42)); + assert_eq!(vdf.get("missing"), None); +} + +#[test] +fn test_vdf_get_path() { + let mut inner = Obj::new(); + inner.insert(Cow::Borrowed("value"), Value::Str(Cow::Borrowed("found"))); + let mut outer = Obj::new(); + outer.insert(Cow::Borrowed("nested"), Value::Obj(inner)); + let vdf = Vdf::new(Cow::Borrowed("root"), Value::Obj(outer)); + + assert_eq!( + vdf.get_path(&["nested", "value"]).and_then(|v| v.as_str()), + Some("found") + ); +} + +#[test] +fn test_vdf_get_str() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("name"), Value::Str(Cow::Borrowed("test"))); + let vdf = Vdf::new(Cow::Borrowed("root"), Value::Obj(obj)); + + assert_eq!(vdf.get_str(&["name"]), Some("test")); +} + +#[test] +fn test_vdf_get_obj() { + let mut inner = Obj::new(); + inner.insert(Cow::Borrowed("k"), Value::I32(1)); + let mut outer = Obj::new(); + outer.insert(Cow::Borrowed("inner"), Value::Obj(inner)); + let vdf = Vdf::new(Cow::Borrowed("root"), Value::Obj(outer)); + + assert!(vdf.get_obj(&["inner"]).is_some()); +} + +#[test] +fn test_vdf_get_i32() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("num"), Value::I32(42)); + let vdf = Vdf::new(Cow::Borrowed("root"), Value::Obj(obj)); + + assert_eq!(vdf.get_i32(&["num"]), Some(42)); +} + +#[test] +fn test_vdf_get_u64() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("big"), Value::U64(12345678901234)); + let vdf = Vdf::new(Cow::Borrowed("root"), Value::Obj(obj)); + + assert_eq!(vdf.get_u64(&["big"]), Some(12345678901234)); +} + +#[test] +fn test_vdf_get_float() { + let mut obj = Obj::new(); + obj.insert(Cow::Borrowed("f"), Value::Float(2.5)); + let vdf = Vdf::new(Cow::Borrowed("root"), Value::Obj(obj)); + + assert_eq!(vdf.get_float(&["f"]), Some(2.5)); +} + +// ============================================================================ +// IntoIterator tests +// ============================================================================ + +#[test] +fn test_obj_into_iter_owned() { + let mut obj = Obj::new(); + obj.insert("key1", "value1".into()); + obj.insert("key2", "value2".into()); + + let mut count = 0; + for (key, value) in obj { + count += 1; + assert!(key == "key1" || key == "key2"); + assert!(value.is_str()); + } + assert_eq!(count, 2); +} + +#[test] +fn test_obj_into_iter_ref() { + let mut obj = Obj::new(); + obj.insert("key1", "value1".into()); + obj.insert("key2", "value2".into()); + + let mut count = 0; + for (key, value) in &obj { + count += 1; + assert!(key.as_ref() == "key1" || key.as_ref() == "key2"); + assert!(value.is_str()); + } + assert_eq!(count, 2); + // obj is still usable after iterating by reference + assert_eq!(obj.len(), 2); +} + +// ============================================================================ +// Index trait tests +// ============================================================================ + +#[test] +fn test_value_index_existing_key() { + let mut obj = Obj::new(); + obj.insert("name", "Alice".into()); + let value = Value::Obj(obj); + + assert_eq!(value["name"].as_str(), Some("Alice")); +} + +#[test] +#[should_panic(expected = "key not found in Value")] +fn test_value_index_missing_key() { + let mut obj = Obj::new(); + obj.insert("name", "Alice".into()); + let value = Value::Obj(obj); + + // This should panic because the key doesn't exist + let _ = &value["nonexistent"]; +} + +#[test] +#[should_panic(expected = "key not found in Value")] +fn test_value_index_on_non_obj() { + let value = Value::Str("not an object".into()); + + // This should panic because indexing a non-object fails + let _ = &value["any"]; +} + +#[test] +fn test_obj_index_existing_key() { + let mut obj = Obj::new(); + obj.insert("count", 42i32.into()); + + assert_eq!(obj["count"].as_i32(), Some(42)); +} + +#[test] +#[should_panic(expected = "key not found in Obj")] +fn test_obj_index_missing_key() { + let obj = Obj::new(); + + // This should panic because the key doesn't exist + let _ = &obj["nonexistent"]; +} + +#[test] +fn test_index_chained_access() { + let mut inner = Obj::new(); + inner.insert("value", "found".into()); + + let mut outer = Obj::new(); + outer.insert("inner", Value::Obj(inner)); + + let value = Value::Obj(outer); + + // Chained indexing works when keys exist + assert_eq!(value["inner"]["value"].as_str(), Some("found")); +} + +#[test] +#[should_panic(expected = "key not found in Value")] +fn test_index_chained_access_missing_key() { + let mut inner = Obj::new(); + inner.insert("value", "found".into()); + + let mut outer = Obj::new(); + outer.insert("inner", Value::Obj(inner)); + + let value = Value::Obj(outer); + + // This should panic because "missing" doesn't exist + let _ = &value["missing"]["anything"]; +} + +// ============================================================================ +// Pretty-print Display tests +// ============================================================================ + +#[test] +fn test_value_pretty_str() { + let value = Value::Str(Cow::Borrowed("hello")); + assert_eq!(format!("{:#}", value), "\"hello\""); +} + +#[test] +fn test_value_pretty_str_with_escapes() { + let value = Value::Str(Cow::Borrowed("line1\nline2\ttab\\backslash\"quote")); + assert_eq!( + format!("{:#}", value), + "\"line1\\nline2\\ttab\\\\backslash\\\"quote\"" + ); +} + +#[test] +fn test_value_pretty_i32() { + let value = Value::I32(42); + assert_eq!(format!("{:#}", value), "\"42\""); +} + +#[test] +fn test_value_pretty_u64() { + let value = Value::U64(123456789); + assert_eq!(format!("{:#}", value), "\"123456789\""); +} + +#[test] +fn test_value_pretty_float() { + let value = Value::Float(1.5); + assert_eq!(format!("{:#}", value), "\"1.5\""); +} + +#[test] +fn test_value_pretty_pointer() { + let value = Value::Pointer(0x12345678); + assert_eq!(format!("{:#}", value), "\"0x12345678\""); +} + +#[test] +fn test_value_pretty_color() { + let value = Value::Color([255, 0, 128, 64]); + assert_eq!(format!("{:#}", value), "\"255 0 128 64\""); +} + +#[test] +fn test_obj_pretty_simple() { + let mut obj = Obj::new(); + obj.insert("key", "value".into()); + let output = format!("{:#}", obj); + // Should contain proper VDF structure + assert!(output.starts_with("{\n")); + assert!(output.ends_with("}")); + assert!(output.contains("\"key\"")); + assert!(output.contains("\"value\"")); +} + +#[test] +fn test_obj_pretty_nested() { + let mut inner = Obj::new(); + inner.insert("inner_key", "inner_value".into()); + let mut outer = Obj::new(); + outer.insert("nested", Value::Obj(inner)); + + let output = format!("{:#}", outer); + // Should have nested structure with proper indentation + assert!(output.contains("\"nested\"")); + assert!(output.contains("\"inner_key\"")); + assert!(output.contains("\"inner_value\"")); + // Check for proper indentation (tabs) + assert!(output.contains("\t\"nested\"")); + assert!(output.contains("\t\t\"inner_key\"")); +} + +#[test] +fn test_vdf_pretty() { + let mut obj = Obj::new(); + obj.insert("key", "value".into()); + let vdf = Vdf::new("root", Value::Obj(obj)); + + let output = format!("{:#}", vdf); + // Should start with quoted key followed by newline and brace + assert!(output.starts_with("\"root\"\n{")); + assert!(output.contains("\"key\"\t\"value\"")); +} + +#[test] +fn test_vdf_pretty_complex() { + let mut inner = Obj::new(); + inner.insert("value", "found".into()); + inner.insert("number", Value::I32(42)); + + let mut outer = Obj::new(); + outer.insert("nested", Value::Obj(inner)); + outer.insert("simple", "string".into()); + + let vdf = Vdf::new("TestVdf", Value::Obj(outer)); + + let output = format!("{:#}", vdf); + // Verify structure + assert!(output.starts_with("\"TestVdf\"\n{"), "output: {}", output); + // The closing brace may or may not have a trailing newline depending on + // whether the last element is a nested object, so just check it ends with } + assert!(output.trim_end().ends_with("}"), "output: {}", output); + // Verify content is present (order may vary due to HashMap) + assert!(output.contains("\"nested\""), "output: {}", output); + assert!( + output.contains("\"simple\"\t\"string\""), + "output: {}", + output + ); + assert!( + output.contains("\"value\"\t\"found\""), + "output: {}", + output + ); + assert!(output.contains("\"number\"\t\"42\""), "output: {}", output); +} + +#[test] +fn test_value_display_produces_vdf_format() { + // Display always produces valid VDF text format + let value = Value::Str(Cow::Borrowed("test")); + assert_eq!(format!("{}", value), "\"test\""); + // Alternate format is the same + assert_eq!(format!("{:#}", value), "\"test\""); +} + +#[test] +fn test_obj_display_produces_vdf_format() { + let mut obj = Obj::new(); + obj.insert("k", "v".into()); + + // Display produces VDF format with newlines and proper quoting + let output = format!("{}", obj); + assert!(output.contains('\n')); + assert!(output.contains("\"k\"")); + assert!(output.contains("\"v\"")); + // Alternate format is the same + assert_eq!(format!("{:#}", obj), output); +} diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/value/types.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/value/types.rs new file mode 100644 index 0000000..2b3a77f --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/src/value/types.rs @@ -0,0 +1,346 @@ +//! Type definitions for VDF values. + +use alloc::borrow::Cow; +use indexmap::IndexMap; + +use super::hasher::DefaultHashBuilder; + +/// A key in VDF - zero-copy when possible +pub(crate) type Key<'text> = Cow<'text, str>; + +/// VDF Value - can be a string, number, object, or other types +#[derive(Clone, Debug, PartialEq)] +pub enum Value<'text> { + /// A string value (text format and WideString from binary) + Str(Cow<'text, str>), + /// An object containing nested key-value pairs + Obj(Obj<'text>), + /// A 32-bit signed integer (binary Int32 type) + I32(i32), + /// A 64-bit unsigned integer (binary UInt64 type) + U64(u64), + /// A 32-bit float (binary Float type) + Float(f32), + /// A pointer value (binary Ptr type, stored as u32) + Pointer(u32), + /// A color value (binary Color type, RGBA) + Color([u8; 4]), +} + +impl<'text> Value<'text> { + /// Returns `true` if this value is a string. + pub fn is_str(&self) -> bool { + matches!(self, Value::Str(_)) + } + + /// Returns `true` if this value is an object. + pub fn is_obj(&self) -> bool { + matches!(self, Value::Obj(_)) + } + + /// Returns `true` if this value is an i32. + pub fn is_i32(&self) -> bool { + matches!(self, Value::I32(_)) + } + + /// Returns `true` if this value is a u64. + pub fn is_u64(&self) -> bool { + matches!(self, Value::U64(_)) + } + + /// Returns `true` if this value is a float. + pub fn is_float(&self) -> bool { + matches!(self, Value::Float(_)) + } + + /// Returns `true` if this value is a pointer. + pub fn is_pointer(&self) -> bool { + matches!(self, Value::Pointer(_)) + } + + /// Returns `true` if this value is a color. + pub fn is_color(&self) -> bool { + matches!(self, Value::Color(_)) + } + + /// Returns a reference to the string value if this is a string. + pub fn as_str(&self) -> Option<&str> { + match self { + Value::Str(s) => Some(s.as_ref()), + _ => None, + } + } + + /// Returns a reference to the object if this is an object. + pub fn as_obj(&self) -> Option<&Obj<'text>> { + match self { + Value::Obj(obj) => Some(obj), + _ => None, + } + } + + /// Returns a mutable reference to the object if this is an object. + pub fn as_obj_mut(&mut self) -> Option<&mut Obj<'text>> { + match self { + Value::Obj(obj) => Some(obj), + _ => None, + } + } + + /// Returns the i32 value if this is an i32. + pub fn as_i32(&self) -> Option<i32> { + match self { + Value::I32(n) => Some(*n), + _ => None, + } + } + + /// Returns the u64 value if this is a u64. + pub fn as_u64(&self) -> Option<u64> { + match self { + Value::U64(n) => Some(*n), + _ => None, + } + } + + /// Returns the float value if this is a float. + pub fn as_float(&self) -> Option<f32> { + match self { + Value::Float(n) => Some(*n), + _ => None, + } + } + + /// Returns the pointer value if this is a pointer. + pub fn as_pointer(&self) -> Option<u32> { + match self { + Value::Pointer(n) => Some(*n), + _ => None, + } + } + + /// Returns the color value if this is a color. + pub fn as_color(&self) -> Option<[u8; 4]> { + match self { + Value::Color(c) => Some(*c), + _ => None, + } + } + + /// Returns a reference to a nested value by key. + /// + /// Shorthand for `self.as_obj()?.get(key)`. + pub fn get(&self, key: &str) -> Option<&Value<'text>> { + self.as_obj()?.get(key) + } + + /// Traverse nested objects by path. + /// + /// Returns `None` if any segment doesn't exist or isn't an object. + pub fn get_path(&self, path: &[&str]) -> Option<&Value<'text>> { + let mut current = self; + for key in path { + current = current.get(key)?; + } + Some(current) + } + + /// Get a string at the given path. + pub fn get_str(&self, path: &[&str]) -> Option<&str> { + self.get_path(path)?.as_str() + } + + /// Get an object at the given path. + pub fn get_obj(&self, path: &[&str]) -> Option<&Obj<'text>> { + self.get_path(path)?.as_obj() + } + + /// Get an i32 at the given path. + pub fn get_i32(&self, path: &[&str]) -> Option<i32> { + self.get_path(path)?.as_i32() + } + + /// Get a u64 at the given path. + pub fn get_u64(&self, path: &[&str]) -> Option<u64> { + self.get_path(path)?.as_u64() + } + + /// Get a float at the given path. + pub fn get_float(&self, path: &[&str]) -> Option<f32> { + self.get_path(path)?.as_float() + } +} + +/// Object - map from keys to values +/// +/// Uses `IndexMap` for O(1) lookup while preserving insertion order. +/// Binary VDF doesn't have duplicate keys, and for text VDF we use +/// "last value wins" semantics. +#[derive(Clone, Debug, PartialEq)] +pub struct Obj<'text> { + pub(crate) inner: IndexMap<Key<'text>, Value<'text>, DefaultHashBuilder>, +} + +impl<'text> Obj<'text> { + /// Creates a new empty VDF object. + pub fn new() -> Self { + Self { + inner: IndexMap::with_hasher(DefaultHashBuilder::default()), + } + } + + /// Returns the number of key-value pairs in the object. + pub fn len(&self) -> usize { + self.inner.len() + } + + /// Returns `true` if the object contains no key-value pairs. + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + /// Returns a reference to the value corresponding to the key. + pub fn get(&self, key: &str) -> Option<&Value<'text>> { + self.inner.get(key) + } + + /// Returns an iterator over the key-value pairs. + pub fn iter(&self) -> impl Iterator<Item = (&Key<'text>, &Value<'text>)> { + self.inner.iter() + } + + /// Returns an iterator over the keys. + pub fn keys(&self) -> impl Iterator<Item = &str> { + self.inner.keys().map(|k| k.as_ref()) + } + + /// Returns an iterator over the values. + pub fn values(&self) -> impl Iterator<Item = &Value<'text>> { + self.inner.values() + } + + /// Returns `true` if the object contains the given key. + pub fn contains_key(&self, key: &str) -> bool { + self.inner.contains_key(key) + } + + /// Returns a mutable reference to the value corresponding to the key. + pub fn get_mut(&mut self, key: &str) -> Option<&mut Value<'text>> { + self.inner.get_mut(key) + } + + /// Inserts a key-value pair into the object. + /// + /// Returns the previous value if one existed for this key. + pub fn insert( + &mut self, + key: impl Into<Key<'text>>, + value: Value<'text>, + ) -> Option<Value<'text>> { + self.inner.insert(key.into(), value) + } + + /// Removes a key from the object, preserving insertion order. + /// + /// This is O(n) as it shifts subsequent elements. Use [`swap_remove`](Self::swap_remove) + /// for O(1) removal when order doesn't matter. + /// + /// Returns the value if the key was present. + pub fn remove(&mut self, key: &str) -> Option<Value<'text>> { + self.inner.shift_remove(key) + } + + /// Removes a key from the object by swapping with the last element. + /// + /// This is O(1) but does not preserve insertion order. + /// Use [`remove`](Self::remove) if order preservation is needed. + /// + /// Returns the value if the key was present. + pub fn swap_remove(&mut self, key: &str) -> Option<Value<'text>> { + self.inner.swap_remove(key) + } +} + +/// Top-level VDF document +/// +/// A VDF document is essentially a single key-value pair at the root level. +#[derive(Clone, Debug, PartialEq)] +pub struct Vdf<'text> { + key: Key<'text>, + value: Value<'text>, +} + +impl<'text> Vdf<'text> { + /// Creates a new VDF document. + pub fn new(key: impl Into<Key<'text>>, value: Value<'text>) -> Self { + Self { + key: key.into(), + value, + } + } + + /// Returns the root key. + pub fn key(&self) -> &str { + &self.key + } + + /// Returns a reference to the root value. + pub fn value(&self) -> &Value<'text> { + &self.value + } + + /// Returns a mutable reference to the root value. + pub fn value_mut(&mut self) -> &mut Value<'text> { + &mut self.value + } + + /// Consumes the Vdf and returns its parts. + pub fn into_parts(self) -> (Cow<'text, str>, Value<'text>) { + (self.key, self.value) + } + + /// Returns `true` if the root value is an object. + pub fn is_obj(&self) -> bool { + self.value.is_obj() + } + + /// Returns a reference to the root object if it is one. + pub fn as_obj(&self) -> Option<&Obj<'text>> { + self.value.as_obj() + } + + /// Returns a reference to a nested value by key. + pub fn get(&self, key: &str) -> Option<&Value<'text>> { + self.value.get(key) + } + + /// Traverse nested objects by path from the root value. + pub fn get_path(&self, path: &[&str]) -> Option<&Value<'text>> { + self.value.get_path(path) + } + + /// Get a string at the given path. + pub fn get_str(&self, path: &[&str]) -> Option<&str> { + self.value.get_str(path) + } + + /// Get an object at the given path. + pub fn get_obj(&self, path: &[&str]) -> Option<&Obj<'text>> { + self.value.get_obj(path) + } + + /// Get an i32 at the given path. + pub fn get_i32(&self, path: &[&str]) -> Option<i32> { + self.value.get_i32(path) + } + + /// Get a u64 at the given path. + pub fn get_u64(&self, path: &[&str]) -> Option<u64> { + self.value.get_u64(path) + } + + /// Get a float at the given path. + pub fn get_float(&self, path: &[&str]) -> Option<f32> { + self.value.get_float(path) + } +} diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/tests/fixtures/appinfo_10.vdf b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/tests/fixtures/appinfo_10.vdf Binary files differnew file mode 100644 index 0000000..39e5137 --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/tests/fixtures/appinfo_10.vdf diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/tests/fixtures/localconfig.vdf b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/tests/fixtures/localconfig.vdf new file mode 100755 index 0000000..c239422 --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/tests/fixtures/localconfig.vdf @@ -0,0 +1,100 @@ +"UserLocalConfigStore" +{ + "Broadcast" + { + "Permissions" "1" + } + "streaming_v2" + { + "EnableStreaming" "1" + "UnlockedH264Feature" "1" + } + "friends" + { + "10000001" + { + "NameHistory" + { + "0" "TestUser01" + } + "avatar" "0000000000000000000000000000000000000000" + "name" "TestUser01" + } + "PersonaName" "TestPersona" + "communitypreferences" "1899cc9284062000280330013800" + "contentdescriptorpreferences" "" + "10000002" + { + "name" "TestUser02" + "NameHistory" + { + "0" "TestUser02" + } + "avatar" "0000000000000000000000000000000000000000" + } + "10000003" + { + "name" "TestUser03" + "NameHistory" + { + "0" "TestUser03" + } + "avatar" "0000000000000000000000000000000000000000" + } + "Notifications_ShowIngame" "1" + "Notifications_ShowOnline" "0" + "Notifications_ShowMessage" "1" + "Notifications_EventsAndAnnouncements" "1" + "Sounds_PlayIngame" "0" + "Sounds_PlayOnline" "0" + "Sounds_PlayMessage" "1" + "Sounds_EventsAndAnnouncements" "0" + "ChatFlashMode" "0" + "DoNotDisturb" "0" + "SignIntoFriends" "0" + } + "ParentalSettings" + { + "settings" "09edc39b07000000004800" + "Signature" "30c818bcbcaad3ccf7597dadeabc2d03f71ce5070700dac271143a8cc9eda2fb32f7317a12e6e0adcda622d4dfc9d46f650ead2a5091c66565f975478091bcf7a510a858d988e41e5dec2fe0fd6365ea7a3be2bb43ed364896e521193cd41379260cc4be9b35f4df382990a545c3c55af8f70aa1f7740ecc8c2880dd9e9b572482690fae060f798237bfb1132bec9307ed8bedeb52de09b1b1e41e19f476811919bb301a99d1fa8c05a91c7a160ee3454064225978ef12fac54640ecb7266b14cb3b02fa79208b644a301b2d0aca1c654d1ce3335d42733e38dbab190dd16eb87ab8bc8d75c0b0efc128e8b18032639c5813895f7110864936e67f7da954562b" + } + "SharedAuth" + { + "id" "1234567890" + "AuthData" "0000000000000000000000000000000000000000000000000000000000000000" + } + "Offline" + { + "Ticket" "00000000000000000" + "Signature" "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "EncryptedTicket" "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "EncryptedSignature" "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + "apptickets" + { + "7" "3200000004000000edc39b0701001001070000005fff515d2301a8c0000000005aa45b69da537769010000000000000000003e6f542da8eb4b0d9dd8290178b4c5a2d0a20b857f60ad18ea51a922c53c7017a0cc352564ebe078da357fdca604dd748439254abd1b23766c424405fa545ae90c8bb944506316543c49a39133b4eff547b53d420969fb2ff892f5299d3f45d0c7e5723adb3ec12daf2532032619db48cb9ee82ba7420747634f3c5a160bb222" + "1282730" "3200000004000000edc39b0701001001aa9213005fff515d2301a8c0000000001a56cb679a05e767010011d20600000000002b6b8cb43dc4fce94043ca172a0cc9cd1f4a6acb14a1c268970cfdf4164b5d156e1cae9631cb326ee4cc30d033f358f6ff680ff85437008059f4bda89fb035cf178febe8b3668a4f668fda95df4c85a5b9598493c0361bdd054df4912b5f88d159d05a117e7534252676a12949aabe9bee25e5c2078861d97a71e8939458d0c8" + } + "AppInfoChangeNumber" "12345678" + "Software" + { + "Valve" + { + "Steam" + { + "BetaPassword" "" + "running" + { + "id" "0" + "Steam2Tickets" + { + } + } + } + } + } + "WebStorage" + { + "keyboard_trackpding_typing_trigger_as_click" "true" + } +} diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/tests/fixtures/packageinfo.vdf b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/tests/fixtures/packageinfo.vdf Binary files differnew file mode 100755 index 0000000..67de2ae --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/tests/fixtures/packageinfo.vdf diff --git a/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/tests/real_files.rs b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/tests/real_files.rs new file mode 100644 index 0000000..6f36aec --- /dev/null +++ b/libs/steam_appinfo_ffi/vendor/steam-vdf-parser/tests/real_files.rs @@ -0,0 +1,123 @@ +//! Integration tests against real Steam VDF files. + +use std::path::Path; +use steam_vdf_parser::{parse_binary, parse_packageinfo, parse_text}; + +#[test] +fn test_parse_real_localconfig_text() { + let path = Path::new("tests/fixtures/localconfig.vdf"); + let content = std::fs::read_to_string(path).expect("Failed to read localconfig.vdf"); + + let result = parse_text(&content); + assert!( + result.is_ok(), + "Failed to parse localconfig.vdf: {:?}", + result.err() + ); + + let vdf = result.unwrap(); + assert_eq!(vdf.key(), "UserLocalConfigStore"); + + let obj = vdf.as_obj().expect("Root should be an object"); + assert!(!obj.is_empty(), "Root should have keys"); + + // Check for known keys that should exist in localconfig + assert!(obj.get("Broadcast").is_some()); + assert!(obj.get("friends").is_some()); +} + +#[test] +fn test_parse_real_appinfo_binary() { + let path = Path::new("tests/fixtures/appinfo_10.vdf"); + let data = std::fs::read(path).expect("Failed to read appinfo_10.vdf"); + + let result = parse_binary(&data); + assert!( + result.is_ok(), + "Failed to parse appinfo_10.vdf: {:?}", + result.err() + ); + + let vdf = result.unwrap(); + assert!(vdf.key().starts_with("appinfo_universe_")); + + let obj = vdf.as_obj().expect("Root should be an object"); + assert!(!obj.is_empty(), "Root should have keys"); + + // appinfo_10.vdf should contain 10 apps (numeric keys as strings) + assert_eq!( + obj.len(), + 10, + "Should have exactly 10 apps in appinfo_10.vdf" + ); +} + +#[test] +fn test_parse_real_packageinfo_binary() { + let path = Path::new("tests/fixtures/packageinfo.vdf"); + let data = std::fs::read(path).expect("Failed to read packageinfo.vdf"); + + let result = parse_packageinfo(&data); + assert!( + result.is_ok(), + "Failed to parse packageinfo.vdf: {:?}", + result.err() + ); + + let vdf = result.unwrap(); + assert!(vdf.key().starts_with("packageinfo_universe_")); + + let obj = vdf.as_obj().expect("Root should be an object"); + assert!(!obj.is_empty(), "Root should have keys"); + + // packageinfo.vdf should contain packages + // Check that we have at least some entries + assert!( + obj.len() > 50, + "Should have many packages in packageinfo.vdf" + ); + + // Check that package 0 exists and has the expected metadata + let pkg0 = obj.get("0").expect("Package 0 should exist"); + let pkg0_obj = pkg0.as_obj().expect("Package 0 should be an object"); + + // Check metadata fields + assert_eq!( + pkg0_obj.get("packageid").and_then(|v| v.as_i32()), + Some(0), + "Package 0 should have packageid = 0" + ); + + assert!( + pkg0_obj + .get("change_number") + .and_then(|v| v.as_u64()) + .is_some(), + "Package 0 should have change_number" + ); + + assert!( + pkg0_obj.get("sha1").and_then(|v| v.as_str()).is_some(), + "Package 0 should have sha1 hash" + ); + + // Check that the VDF data is present under the "0" key + let vdf_data = pkg0_obj + .get("0") + .expect("Package 0 should have VDF data under '0' key"); + let vdf_obj = vdf_data.as_obj().expect("VDF data should be an object"); + + // Check for known fields in the VDF data + assert!( + vdf_obj.get("packageid").is_some(), + "VDF data should contain packageid" + ); + assert!( + vdf_obj.get("billingtype").is_some(), + "VDF data should contain billingtype" + ); + assert!( + vdf_obj.get("licensetype").is_some(), + "VDF data should contain licensetype" + ); +} diff --git a/libs/uibase/src/CMakeLists.txt b/libs/uibase/src/CMakeLists.txt index 5660289..c823290 100644 --- a/libs/uibase/src/CMakeLists.txt +++ b/libs/uibase/src/CMakeLists.txt @@ -188,6 +188,10 @@ target_link_libraries(uibase PRIVATE spdlog::spdlog_header_only Qt6::Qml Qt6::Quick) if(NOT WIN32) + target_link_libraries(uibase PRIVATE mo2::steam_appinfo_ffi) +endif() + +if(NOT WIN32) # Native C++ ports of game detection, Steam/Proton detection, SLR management, # icon extraction, and prefix symlinks (formerly in Rust nak_ffi). # Use BUILD_INTERFACE to avoid "prefixed in the source directory" CMake error. @@ -200,6 +204,7 @@ if(NOT WIN32) ${CMAKE_SOURCE_DIR}/src/src/iconextractor.cpp ${CMAKE_SOURCE_DIR}/src/src/prefixsymlinks.cpp ${CMAKE_SOURCE_DIR}/src/src/slrmanager.cpp + ${CMAKE_SOURCE_DIR}/src/src/steamappinfo.cpp ) endif() |
