Five confirmed findings from the 2026-06-22 adversarial bug sweep: - S-01: clamp PipeWire capture chunk size to the mapped slice before indexing, so a bad reported size can't panic (= process abort) from the RT capture callback. Extracted testable for_each_capture_sample. - F-04: reserve ring occupancy before publishing a frame on the PipeWire playback path (mirrors the cpal fix), preventing the RT consumer from popping an uncounted sample and wrapping fill_gauge to usize::MAX, which permanently wedged mixer pacing. Extracted publish_frame. - F-09: GameDetector::spawn now returns io::Result and retains its JoinHandle (joined on Drop); core fuses a closed watch receiver to None via next_game_change so a dead detector can't busy-loop select!. - F-08: collision-free recording paths — Recorder::create and the multitrack session dir use create_new/create_dir with bounded suffix retry, so two recordings in the same second no longer truncate the first. - S-02: bound the Windows SteamPath registry read (<=4 KiB, even length, re-checked type/returned length) before allocating/decoding. 403 lib tests pass (+6), clippy --all-targets clean. Implemented by Codex, reviewed + gates re-run by senior. Co-Authored-By: Codex <codex@openai.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
545 lines
20 KiB
Rust
545 lines
20 KiB
Rust
//! Steam detection adapter: the primary signal (D1). Reads Steam's live
|
|
//! `RunningAppID` and resolves it to a display name via the plain-text
|
|
//! `appmanifest_<appid>.acf`, with no dependency on the binary `appinfo.vdf`.
|
|
//!
|
|
//! The *parsing* is pure and unit-tested ([`parse_running_app_id`],
|
|
//! [`parse_library_paths`], [`parse_app_name`], all over file contents). The fs /
|
|
//! Windows-registry reads are the thin edge, and [`SteamProbe`] caches roots,
|
|
//! library list, and resolved names — invalidating by mtime — so the 3 s detector
|
|
//! poll does not rescan every library each tick (Codex hardening).
|
|
|
|
use super::vdf::{self, Value};
|
|
use super::{DetectedGame, GameSource};
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::SystemTime;
|
|
|
|
/// Max bytes read from any single Steam state file. These are small text files
|
|
/// (a manifest is a few KB); the cap stops a corrupt/hostile giant file from being
|
|
/// slurped into memory before the parser's own depth guard kicks in.
|
|
const MAX_STEAM_FILE_BYTES: u64 = 4 * 1024 * 1024;
|
|
/// SteamPath is a local filesystem path. Four KiB is deliberately generous and
|
|
/// prevents a corrupt registry length from driving an enormous allocation.
|
|
#[cfg(any(windows, test))]
|
|
const MAX_STEAM_PATH_BYTES: u32 = 4 * 1024;
|
|
|
|
#[cfg(any(windows, test))]
|
|
fn validate_reg_len(len: u32) -> Option<usize> {
|
|
(len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES)
|
|
.then_some(len as usize / 2)
|
|
}
|
|
|
|
#[cfg(any(windows, test))]
|
|
fn decode_reg_sz(mut buf: Vec<u16>, returned_bytes: u32) -> Option<String> {
|
|
let units = validate_reg_len(returned_bytes)?;
|
|
if units > buf.len() {
|
|
return None;
|
|
}
|
|
buf.truncate(units);
|
|
while buf.last() == Some(&0) {
|
|
buf.pop();
|
|
}
|
|
Some(String::from_utf16_lossy(&buf))
|
|
}
|
|
|
|
/// Parse the live `RunningAppID` out of a Steam `registry.vdf` (the Linux/macOS
|
|
/// client's emulated-registry text file). Returns the appid only when present and
|
|
/// nonzero — `0`/absent is the "no game" state. Pure.
|
|
pub fn parse_running_app_id(registry_vdf: &str) -> Option<u32> {
|
|
let root = vdf::parse(registry_vdf).ok()?;
|
|
let raw = root
|
|
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"])
|
|
.and_then(Value::as_str)?;
|
|
let id: u32 = raw.trim().parse().ok()?;
|
|
(id != 0).then_some(id)
|
|
}
|
|
|
|
/// Parse the library folder paths out of a `libraryfolders.vdf`, handling **both**
|
|
/// the current shape (`"0" { "path" "..." }`) and the legacy shape
|
|
/// (`"1" "/path"`, the path as a direct string value). Non-numeric keys
|
|
/// (`contentstatsid`, …) are skipped. Pure; paths are returned as-is (escapes
|
|
/// already decoded by the VDF parser), including ones on offline drives — the
|
|
/// caller checks existence.
|
|
pub fn parse_library_paths(libraryfolders_vdf: &str) -> Vec<PathBuf> {
|
|
let Ok(root) = vdf::parse(libraryfolders_vdf) else {
|
|
return Vec::new();
|
|
};
|
|
// The root may or may not wrap entries in a "libraryfolders" object.
|
|
let container = root.get("libraryfolders").unwrap_or(&root);
|
|
let mut out = Vec::new();
|
|
for (key, val) in container.entries() {
|
|
// Only numeric-keyed entries are library folders.
|
|
if key.parse::<u32>().is_err() {
|
|
continue;
|
|
}
|
|
let path = match val {
|
|
Value::Str(s) => Some(s.as_str()),
|
|
Value::Obj(_) => val.get("path").and_then(Value::as_str),
|
|
};
|
|
if let Some(p) = path
|
|
&& !p.is_empty()
|
|
{
|
|
out.push(PathBuf::from(p));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Parse the human `name` out of an `appmanifest_<appid>.acf`. Pure.
|
|
pub fn parse_app_name(appmanifest_acf: &str) -> Option<String> {
|
|
let root = vdf::parse(appmanifest_acf).ok()?;
|
|
root.get_path(&["AppState", "name"])
|
|
.and_then(Value::as_str)
|
|
.map(|s| s.to_string())
|
|
.filter(|s| !s.is_empty())
|
|
}
|
|
|
|
/// Read at most [`MAX_STEAM_FILE_BYTES`] of a file as UTF-8 (lossy), or `None` if
|
|
/// it is missing/unreadable. The thin fs edge under the pure parsers above.
|
|
fn read_capped(path: &Path) -> Option<String> {
|
|
use std::io::Read;
|
|
let file = std::fs::File::open(path).ok()?;
|
|
let mut buf = Vec::new();
|
|
file.take(MAX_STEAM_FILE_BYTES).read_to_end(&mut buf).ok()?;
|
|
Some(String::from_utf8_lossy(&buf).into_owned())
|
|
}
|
|
|
|
fn mtime_of(path: &Path) -> Option<SystemTime> {
|
|
std::fs::metadata(path).ok()?.modified().ok()
|
|
}
|
|
|
|
/// A library list cached against its source file's mtime.
|
|
#[derive(Default)]
|
|
struct CachedLibraries {
|
|
source: Option<PathBuf>,
|
|
mtime: Option<SystemTime>,
|
|
paths: Vec<PathBuf>,
|
|
}
|
|
|
|
/// A per-appid resolved name cached against the manifest's mtime. `name` is `None`
|
|
/// when the manifest exists but carries no usable name, or wasn't found.
|
|
struct CachedManifest {
|
|
mtime: Option<SystemTime>,
|
|
name: Option<String>,
|
|
}
|
|
|
|
/// Stateful Steam probe with mtime-invalidated caches. Construct once and call
|
|
/// [`detect`](Self::detect) each poll; all reads are blocking, so the detector
|
|
/// service runs it off the async worker.
|
|
pub struct SteamProbe {
|
|
roots: Vec<PathBuf>,
|
|
libraries: CachedLibraries,
|
|
manifests: HashMap<u32, CachedManifest>,
|
|
}
|
|
|
|
impl Default for SteamProbe {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl SteamProbe {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
roots: discover_roots(),
|
|
libraries: CachedLibraries::default(),
|
|
manifests: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// One detection pass: read the live `RunningAppID`, and if a game is running,
|
|
/// resolve its name from the appmanifest (cached). Returns a `DetectedGame`
|
|
/// with `name: None` when the appid is known but no manifest name is available
|
|
/// — the background can still switch by id, but presence must not invent a name.
|
|
pub fn detect(&mut self) -> Option<DetectedGame> {
|
|
let app_id = self.running_app_id()?;
|
|
let name = self.app_name(app_id);
|
|
Some(DetectedGame {
|
|
id: DetectedGame::steam_id(app_id),
|
|
name,
|
|
source: GameSource::Steam,
|
|
})
|
|
}
|
|
|
|
/// The live RunningAppID (nonzero), or `None`.
|
|
///
|
|
/// Platform notes: on **Windows** the real registry's `RunningAppID` is updated
|
|
/// live, so we read it. On **Linux** the client's `registry.vdf` is only
|
|
/// rewritten on Steam *shutdown* — it's stale while a game runs — so the live
|
|
/// signal is the running game process's `SteamAppId` environment variable
|
|
/// (`/proc/<pid>/environ`, readable for our own processes; the same approach
|
|
/// MangoHud uses); `registry.vdf` stays as a best-effort fallback. Other Unix
|
|
/// (macOS) only has the `registry.vdf` fallback for now.
|
|
fn running_app_id(&self) -> Option<u32> {
|
|
#[cfg(windows)]
|
|
{
|
|
win::running_app_id()
|
|
}
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
running_app_id_from_environ().or_else(registry_running_app_id)
|
|
}
|
|
#[cfg(not(any(windows, target_os = "linux")))]
|
|
{
|
|
registry_running_app_id()
|
|
}
|
|
}
|
|
|
|
/// Resolve (and cache) the display name for an appid by locating its
|
|
/// `appmanifest_<appid>.acf` across the known libraries.
|
|
fn app_name(&mut self, app_id: u32) -> Option<String> {
|
|
let manifest = self.find_manifest(app_id)?;
|
|
let mtime = mtime_of(&manifest);
|
|
if let Some(cached) = self.manifests.get(&app_id)
|
|
&& cached.mtime == mtime
|
|
{
|
|
return cached.name.clone();
|
|
}
|
|
let name = read_capped(&manifest).and_then(|c| parse_app_name(&c));
|
|
self.manifests.insert(app_id, CachedManifest { mtime, name: name.clone() });
|
|
name
|
|
}
|
|
|
|
/// The path to an appid's manifest, if it exists in any library.
|
|
fn find_manifest(&mut self, app_id: u32) -> Option<PathBuf> {
|
|
let filename = format!("appmanifest_{app_id}.acf");
|
|
for lib in self.library_paths() {
|
|
let candidate = lib.join("steamapps").join(&filename);
|
|
if candidate.exists() {
|
|
return Some(candidate);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// All Steam library folder paths, cached and refreshed only when the source
|
|
/// `libraryfolders.vdf` changes (mtime). Discovered from the known roots.
|
|
fn library_paths(&mut self) -> Vec<PathBuf> {
|
|
// Locate the libraryfolders.vdf to watch (first existing across roots).
|
|
let source = self
|
|
.roots
|
|
.iter()
|
|
.map(|r| r.join("steamapps").join("libraryfolders.vdf"))
|
|
.find(|p| p.exists());
|
|
|
|
let mtime = source.as_deref().and_then(mtime_of);
|
|
if self.libraries.source == source && self.libraries.mtime == mtime && source.is_some() {
|
|
return self.libraries.paths.clone();
|
|
}
|
|
|
|
let mut paths = Vec::new();
|
|
if let Some(ref src) = source
|
|
&& let Some(contents) = read_capped(src)
|
|
{
|
|
paths = parse_library_paths(&contents);
|
|
}
|
|
// Always include the roots themselves: the install dir is an implicit
|
|
// library even if libraryfolders.vdf is missing or lists only extras.
|
|
for root in &self.roots {
|
|
if !paths.contains(root) {
|
|
paths.push(root.clone());
|
|
}
|
|
}
|
|
self.libraries = CachedLibraries { source, mtime, paths: paths.clone() };
|
|
paths
|
|
}
|
|
}
|
|
|
|
/// Candidate Steam install roots that actually exist on this machine (each is a
|
|
/// directory containing a `steamapps` folder). Covers native, Flatpak, and Snap
|
|
/// layouts on Linux; on Windows the install path comes from the registry.
|
|
fn discover_roots() -> Vec<PathBuf> {
|
|
let mut roots = Vec::new();
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
if let Some(p) = win::install_path() {
|
|
roots.push(p);
|
|
}
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
{
|
|
if let Some(home) = dirs::home_dir() {
|
|
for rel in [
|
|
".steam/steam",
|
|
".steam/root",
|
|
".local/share/Steam",
|
|
".var/app/com.valvesoftware.Steam/.local/share/Steam",
|
|
"snap/steam/common/.local/share/Steam",
|
|
] {
|
|
roots.push(home.join(rel));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keep only roots that exist and look like a Steam install.
|
|
roots.retain(|p| p.join("steamapps").is_dir());
|
|
roots.sort();
|
|
roots.dedup();
|
|
roots
|
|
}
|
|
|
|
/// Candidate `registry.vdf` locations (Linux/macOS emulated registry).
|
|
#[cfg(not(windows))]
|
|
fn registry_vdf_candidates() -> Vec<PathBuf> {
|
|
let mut out = Vec::new();
|
|
if let Some(home) = dirs::home_dir() {
|
|
out.push(home.join(".steam/registry.vdf"));
|
|
out.push(home.join(".steam/steam/registry.vdf"));
|
|
out.push(home.join(".var/app/com.valvesoftware.Steam/.steam/registry.vdf"));
|
|
out.push(home.join("snap/steam/common/.steam/registry.vdf"));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Best-effort `RunningAppID` from the on-disk `registry.vdf`. ⚠️ Stale while a
|
|
/// game runs (Steam rewrites the file only on shutdown), so this is a *fallback*
|
|
/// behind the live `/proc` `SteamAppId` scan on Linux — not the primary signal.
|
|
#[cfg(not(windows))]
|
|
fn registry_running_app_id() -> Option<u32> {
|
|
for path in registry_vdf_candidates() {
|
|
if let Some(contents) = read_capped(&path)
|
|
&& let Some(id) = parse_running_app_id(&contents)
|
|
{
|
|
return Some(id);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Parse a Steam appid out of a process's raw `environ` blob (NUL-separated
|
|
/// `KEY=VALUE` pairs), reading the `SteamAppId` variable Steam exports to every
|
|
/// game process. Returns the appid only when present and nonzero. Pure +
|
|
/// unit-tested; the `/proc` iteration is the thin edge in
|
|
/// [`running_app_id_from_environ`].
|
|
#[cfg(target_os = "linux")]
|
|
pub fn parse_steam_app_id_from_environ(environ: &[u8]) -> Option<u32> {
|
|
for kv in environ.split(|&b| b == 0) {
|
|
if let Some(val) = kv.strip_prefix(b"SteamAppId=")
|
|
&& let Ok(s) = std::str::from_utf8(val)
|
|
&& let Ok(id) = s.trim().parse::<u32>()
|
|
&& id != 0
|
|
{
|
|
return Some(id);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// The live Steam appid of a running game, found by scanning `/proc/<pid>/environ`
|
|
/// for the `SteamAppId` Steam exports to the game's process tree. `environ` is
|
|
/// readable only for our own processes — exactly the ones a Steam game we launched
|
|
/// runs as — and we skip the rest. The live signal that replaces the stale
|
|
/// on-disk `registry.vdf` on Linux.
|
|
#[cfg(target_os = "linux")]
|
|
fn running_app_id_from_environ() -> Option<u32> {
|
|
let entries = std::fs::read_dir("/proc").ok()?;
|
|
for entry in entries.flatten() {
|
|
let name = entry.file_name();
|
|
let Some(name) = name.to_str() else { continue };
|
|
if !name.bytes().all(|b| b.is_ascii_digit()) {
|
|
continue;
|
|
}
|
|
// Cap the read: an environ is small; this bounds a pathological case.
|
|
if let Some(environ) = read_capped(&entry.path().join("environ"))
|
|
&& let Some(id) = parse_steam_app_id_from_environ(environ.as_bytes())
|
|
{
|
|
return Some(id);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
mod win {
|
|
//! Windows registry reads via direct Win32 FFI (windows-sys), no `winreg`
|
|
//! crate. Steam stores both the live `RunningAppID` and its install path under
|
|
//! `HKCU\Software\Valve\Steam`.
|
|
use super::{decode_reg_sz, validate_reg_len};
|
|
use std::path::PathBuf;
|
|
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
|
use windows_sys::Win32::System::Registry::{
|
|
RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, KEY_READ,
|
|
REG_DWORD, REG_SZ,
|
|
};
|
|
|
|
/// UTF-16, NUL-terminated, for a Win32 wide-string argument.
|
|
fn wide(s: &str) -> Vec<u16> {
|
|
s.encode_utf16().chain(std::iter::once(0)).collect()
|
|
}
|
|
|
|
/// Open `HKCU\Software\Valve\Steam` for reading; `None` if absent.
|
|
fn open_steam_key() -> Option<HKEY> {
|
|
let subkey = wide("Software\\Valve\\Steam");
|
|
let mut hkey: HKEY = std::ptr::null_mut();
|
|
// SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle.
|
|
let rc = unsafe {
|
|
RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey)
|
|
};
|
|
(rc == ERROR_SUCCESS).then_some(hkey)
|
|
}
|
|
|
|
/// The live `RunningAppID` REG_DWORD, nonzero, or `None`.
|
|
pub fn running_app_id() -> Option<u32> {
|
|
let hkey = open_steam_key()?;
|
|
let name = wide("RunningAppID");
|
|
let mut kind: u32 = 0;
|
|
let mut data: u32 = 0;
|
|
let mut len = std::mem::size_of::<u32>() as u32;
|
|
// SAFETY: out-params sized for a DWORD; data buffer is a u32 we own.
|
|
let rc = unsafe {
|
|
RegQueryValueExW(
|
|
hkey,
|
|
name.as_ptr(),
|
|
std::ptr::null(),
|
|
&mut kind,
|
|
&mut data as *mut u32 as *mut u8,
|
|
&mut len,
|
|
)
|
|
};
|
|
// SAFETY: handle came from RegOpenKeyExW above.
|
|
unsafe { RegCloseKey(hkey) };
|
|
if rc == ERROR_SUCCESS && kind == REG_DWORD && data != 0 {
|
|
Some(data)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// The Steam install directory from `HKCU\...\Steam\SteamPath`, if it exists.
|
|
pub fn install_path() -> Option<PathBuf> {
|
|
let hkey = open_steam_key()?;
|
|
let name = wide("SteamPath");
|
|
let mut kind: u32 = 0;
|
|
let mut len: u32 = 0;
|
|
// First query the size.
|
|
// SAFETY: null data ptr with a zeroed len asks for the required size.
|
|
let rc = unsafe {
|
|
RegQueryValueExW(
|
|
hkey,
|
|
name.as_ptr(),
|
|
std::ptr::null(),
|
|
&mut kind,
|
|
std::ptr::null_mut(),
|
|
&mut len,
|
|
)
|
|
};
|
|
if rc != ERROR_SUCCESS || kind != REG_SZ {
|
|
// SAFETY: valid handle.
|
|
unsafe { RegCloseKey(hkey) };
|
|
return None;
|
|
}
|
|
let Some(units) = validate_reg_len(len) else {
|
|
// SAFETY: valid handle.
|
|
unsafe { RegCloseKey(hkey) };
|
|
return None;
|
|
};
|
|
let mut buf = vec![0u16; units];
|
|
let mut len2 = len;
|
|
// SAFETY: buffer sized to the queried byte length.
|
|
let rc = unsafe {
|
|
RegQueryValueExW(
|
|
hkey,
|
|
name.as_ptr(),
|
|
std::ptr::null(),
|
|
&mut kind,
|
|
buf.as_mut_ptr() as *mut u8,
|
|
&mut len2,
|
|
)
|
|
};
|
|
// SAFETY: valid handle.
|
|
unsafe { RegCloseKey(hkey) };
|
|
if rc != ERROR_SUCCESS || kind != REG_SZ || len2 > len {
|
|
return None;
|
|
}
|
|
Some(PathBuf::from(decode_reg_sz(buf, len2)?))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn registry_string_lengths_are_bounded_and_trimmed() {
|
|
assert_eq!(validate_reg_len(5), None, "odd byte lengths are invalid UTF-16");
|
|
assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None);
|
|
assert_eq!(validate_reg_len(8), Some(4));
|
|
|
|
let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>();
|
|
let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32;
|
|
assert_eq!(decode_reg_sz(raw, returned_bytes).as_deref(), Some("C:\\Steam"));
|
|
}
|
|
|
|
#[test]
|
|
fn running_app_id_reads_nonzero_and_rejects_zero() {
|
|
let running = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
|
|
"RunningAppID" "440"
|
|
} } } } }"#;
|
|
assert_eq!(parse_running_app_id(running), Some(440));
|
|
let idle = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
|
|
"RunningAppID" "0"
|
|
} } } } }"#;
|
|
assert_eq!(parse_running_app_id(idle), None);
|
|
// Missing key / garbage → None, no panic.
|
|
assert_eq!(parse_running_app_id(r#""Registry" { }"#), None);
|
|
assert_eq!(parse_running_app_id("not vdf at all {{{"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn library_paths_handles_current_and_legacy_shapes() {
|
|
let current = r#""libraryfolders" {
|
|
"0" { "path" "/home/eric/.local/share/Steam" "label" "" }
|
|
"1" { "path" "/mnt/games/SteamLibrary" }
|
|
"contentstatsid" "12345"
|
|
}"#;
|
|
let got = parse_library_paths(current);
|
|
assert_eq!(got, vec![
|
|
PathBuf::from("/home/eric/.local/share/Steam"),
|
|
PathBuf::from("/mnt/games/SteamLibrary"),
|
|
]);
|
|
|
|
// Legacy shape: numeric keys map straight to path strings.
|
|
let legacy = r#""LibraryFolders" {
|
|
"TimeNextStatsReport" "9999"
|
|
"ContentStatsID" "42"
|
|
"1" "/mnt/old/SteamLibrary"
|
|
}"#;
|
|
let got = parse_library_paths(legacy);
|
|
assert_eq!(got, vec![PathBuf::from("/mnt/old/SteamLibrary")]);
|
|
}
|
|
|
|
#[test]
|
|
fn library_paths_empty_on_garbage() {
|
|
assert!(parse_library_paths("totally broken {{{").is_empty());
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn steam_app_id_parsed_from_environ_blob() {
|
|
// A realistic NUL-separated environ with SteamAppId among other vars.
|
|
let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0";
|
|
assert_eq!(parse_steam_app_id_from_environ(environ), Some(440));
|
|
// Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored.
|
|
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"), None);
|
|
// Absent → None (a non-Steam process).
|
|
assert_eq!(parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"), None);
|
|
// Not fooled by a different var that merely contains the substring.
|
|
assert_eq!(parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"), None);
|
|
// Garbage value → None, no panic.
|
|
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn app_name_extracts_and_filters_empty() {
|
|
let acf = r#""AppState" { "appid" "440" "name" "Team Fortress 2" }"#;
|
|
assert_eq!(parse_app_name(acf), Some("Team Fortress 2".to_string()));
|
|
// Empty name → None (don't broadcast a blank).
|
|
let blank = r#""AppState" { "appid" "440" "name" "" }"#;
|
|
assert_eq!(parse_app_name(blank), None);
|
|
// Missing name → None.
|
|
assert_eq!(parse_app_name(r#""AppState" { "appid" "440" }"#), None);
|
|
}
|
|
}
|