thunderstore_engine/steam.rs
1//! Host facts about the Steam client: where its executable lives, and whether it
2//! can resolve a path a caller hands it.
3//!
4//! # Locating the executable
5//!
6//! [`crate::steam::steam_exe`] answers "what program starts Steam on this
7//! machine". Steam is on `PATH` on Linux, so a bare `steam` is correct there,
8//! but its Windows installer adds nothing to `PATH`: `CreateProcess` appends
9//! `.exe` to a bare program name yet still has nowhere to look, so the spawn
10//! fails outright and the ordinary Steam configuration cannot launch at all.
11//!
12//! # Namespace visibility
13//!
14//! Steam's launch options are a string the caller never opens itself: it names
15//! a wrapper script and a target directory, and *Steam* is what has to resolve
16//! them. On a sandboxed Steam those are resolved in a different mount
17//! namespace, so a path that exists perfectly well for the caller can be
18//! absent for Steam. When that happens nothing on the caller's side fails,
19//! since `steam -applaunch` returns 0 either way, and the only record is a
20//! line in Steam's own console log. The launch simply appears to do nothing.
21//!
22//! This module makes that failure loud *before* a launch, by asking the running
23//! Steam client's own mount namespace whether it can see the path.
24//!
25//! This is host inspection, shared here in the engine rather than kept private
26//! to one client, because every client on Linux hits sandboxed Steam (Flatpak,
27//! Snap, containerized), and the failure mode is a launch that appears to do
28//! nothing, with the only record in Steam's own console log. Sharing the probe
29//! is worth the impurity of reading the host machine.
30
31#![deny(missing_docs)]
32
33use std::path::{Path, PathBuf};
34
35/// Path prefixes a sandboxed Steam is known to replace with a private mount, used
36/// only when no running Steam client is available to ask directly.
37///
38/// These are per-namespace or per-boot scratch locations that no persistent game
39/// data belongs in regardless of Steam, so flagging them is honest even against an
40/// unsandboxed Steam that could in fact read them.
41const SUSPECT_PREFIXES: [&str; 4] = ["/tmp", "/var/tmp", "/dev/shm", "/run/user"];
42
43/// What was determined about Steam's ability to resolve a path.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum Visibility {
46 /// Present in the running Steam client's mount namespace.
47 Reachable,
48 /// Proven absent from the running Steam client's mount namespace. A launch using
49 /// this path would fail inside Steam and report nowhere the caller can see.
50 Unreachable {
51 /// PID of the running Steam client whose mount namespace was inspected.
52 steam_pid: u32,
53 },
54 /// No running Steam client could be inspected, so nothing was proven. `suspect`
55 /// marks a path under a `SUSPECT_PREFIXES` entry, which is the shape of path
56 /// that fails once Steam *is* sandboxed.
57 Unknown {
58 /// Whether the path sits under a prefix a sandboxed Steam is known to
59 /// replace with a private mount.
60 suspect: bool,
61 },
62}
63
64/// How a caller should treat a [`Visibility`] verdict.
65///
66/// The mapping lives here so every client agrees on refuse-versus-warn; the
67/// wording of each refusal or warning is each client's own.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Severity {
70 /// Nothing to report.
71 Ok,
72 /// Report a warning but proceed: the suspicion is unconfirmed.
73 Warn,
74 /// Refuse: a live Steam client was inspected and genuinely lacks the path.
75 Refuse,
76}
77
78impl Visibility {
79 /// How a caller should treat this verdict.
80 ///
81 /// Only a proven absence refuses. An unproven suspicion warns, because
82 /// reporting it as a failure would be a claim the evidence does not support.
83 pub fn severity(&self) -> Severity {
84 match self {
85 Self::Reachable | Self::Unknown { suspect: false } => Severity::Ok,
86 Self::Unknown { suspect: true } => Severity::Warn,
87 Self::Unreachable { .. } => Severity::Refuse,
88 }
89 }
90}
91
92/// Whether `path` starts with a `SUSPECT_PREFIXES` entry.
93///
94/// Compared component-wise rather than as a string prefix, so `/tmpfoo` is not
95/// mistaken for something under `/tmp`.
96pub fn suspect_prefix(path: &Path) -> bool {
97 SUSPECT_PREFIXES
98 .iter()
99 .any(|prefix| starts_with_components(path, Path::new(prefix)))
100}
101
102/// Whether every component of `prefix`, in order, opens `path`.
103fn starts_with_components(path: &Path, prefix: &Path) -> bool {
104 let mut components = path.components();
105
106 for expected in prefix.components() {
107 match components.next() {
108 Some(actual) if actual == expected => {}
109 _ => return false,
110 }
111 }
112
113 true
114}
115
116/// Whether `path` resolves inside the filesystem rooted at `namespace_root`.
117///
118/// `namespace_root` is `/proc/<pid>/root` in real use, which the kernel resolves
119/// against that process's root, including absolute symlinks encountered along the
120/// way, so this genuinely answers "can that process open this path", not merely
121/// "does a similarly named file exist here". Taking the root as a parameter is also
122/// what makes the resolution testable against an ordinary directory tree.
123///
124/// `path`'s leading `/` is stripped before joining, since [`Path::join`] with an
125/// absolute path discards the root it is joined onto.
126fn visible_in(namespace_root: &Path, path: &Path) -> bool {
127 let relative = path.strip_prefix("/").unwrap_or(path);
128
129 namespace_root.join(relative).exists()
130}
131
132/// The running Steam client's pid, as Steam itself records it.
133///
134/// Read from `~/.steam/steam.pid`, which the client writes on startup, and
135/// accepted only if that pid currently belongs to a process named `steam`. A pid
136/// file outlives the process that wrote it, and a recycled pid would otherwise
137/// have us inspecting an unrelated namespace and reporting nonsense about it.
138pub fn steam_client_pid() -> Option<u32> {
139 let home = std::env::var_os("HOME")?;
140 let pid_file = PathBuf::from(home).join(".steam").join("steam.pid");
141 let pid: u32 = std::fs::read_to_string(pid_file)
142 .ok()?
143 .trim()
144 .parse()
145 .ok()?;
146
147 let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?;
148
149 (comm.trim() == "steam").then_some(pid)
150}
151
152/// Whether the running Steam client can resolve `path`.
153///
154/// Returns [`Visibility::Unknown`] whenever no Steam client can be inspected, be
155/// that Steam not running, a platform without `/proc`, or an unreadable pid file,
156/// because an unproven suspicion must not be reported as a proven failure.
157pub fn visibility(path: &Path) -> Visibility {
158 let Some(steam_pid) = steam_client_pid() else {
159 return Visibility::Unknown {
160 suspect: suspect_prefix(path),
161 };
162 };
163
164 let namespace_root = PathBuf::from(format!("/proc/{steam_pid}/root"));
165
166 // An unreadable namespace root proves nothing either way; only a readable one
167 // that lacks the path is evidence.
168 if !namespace_root.is_dir() {
169 return Visibility::Unknown {
170 suspect: suspect_prefix(path),
171 };
172 }
173
174 if visible_in(&namespace_root, path) {
175 return Visibility::Reachable;
176 }
177
178 Visibility::Unreachable { steam_pid }
179}
180
181/// The registry key Steam's Windows installer writes its own location into.
182const STEAM_REGISTRY_KEY: &str = r"Software\Valve\Steam";
183
184/// The value under [`STEAM_REGISTRY_KEY`] holding the full path to `steam.exe`.
185const STEAM_REGISTRY_VALUE: &str = "SteamExe";
186
187/// Environment variables naming the roots Steam installs under, in the order its
188/// installer prefers: a 32-bit application on 64-bit Windows lands in
189/// `Program Files (x86)`, and Steam is one.
190const PROGRAM_FILES_VARS: [&str; 2] = ["ProgramFiles(x86)", "ProgramFiles"];
191
192/// The directory Steam creates under a Program Files root.
193const STEAM_INSTALL_DIR: &str = "Steam";
194
195/// The Steam client executable's file name on Windows.
196const STEAM_EXE_NAME: &str = "steam.exe";
197
198/// The program name used when no executable can be located.
199///
200/// Correct on Linux and macOS, where the client is on `PATH`, and no worse than a
201/// bare `steam` on a Windows host that has had Steam put on `PATH` by hand.
202const STEAM_FALLBACK: &str = "steam";
203
204/// `steam.exe` under each root's `Steam` directory, in the order given.
205///
206/// Pure: proposes paths without touching the filesystem, so the preference order
207/// can be asserted on any host.
208fn steam_exe_candidates(roots: &[PathBuf]) -> Vec<PathBuf> {
209 roots
210 .iter()
211 .map(|root| root.join(STEAM_INSTALL_DIR).join(STEAM_EXE_NAME))
212 .collect()
213}
214
215/// The first candidate that is a file, or `None` when none is.
216///
217/// Split out from where the candidates come from so the "which one wins"
218/// decision is testable against an ordinary directory tree, with no Windows and
219/// no Steam.
220fn first_existing(candidates: &[PathBuf]) -> Option<PathBuf> {
221 candidates
222 .iter()
223 .find(|candidate| candidate.is_file())
224 .cloned()
225}
226
227/// The Program Files roots to search, read from the environment.
228///
229/// Guarded at run time rather than with `#[cfg(windows)]` so the resolution path
230/// stays compiled and unit-tested on a Linux CI, which is the only place this can
231/// be tested at all. The guard is load-bearing beyond that: Wine and some CI
232/// images export `ProgramFiles`, and honouring it off Windows would divert a
233/// working launch to a path that cannot be executed.
234fn install_roots() -> Vec<PathBuf> {
235 if !cfg!(windows) {
236 return Vec::new();
237 }
238
239 PROGRAM_FILES_VARS
240 .iter()
241 .filter_map(std::env::var_os)
242 .map(PathBuf::from)
243 .collect()
244}
245
246/// The executable path Steam's installer recorded in the registry.
247///
248/// Preferred over the standard roots because it is the only source that survives
249/// a Steam installed to another drive. Steam writes this value with forward
250/// slashes, which Windows accepts.
251#[cfg(windows)]
252fn registry_steam_exe() -> Option<PathBuf> {
253 let key = windows_registry::CURRENT_USER
254 .open(STEAM_REGISTRY_KEY)
255 .ok()?;
256 let recorded = key.get_string(STEAM_REGISTRY_VALUE).ok()?;
257
258 (!recorded.trim().is_empty()).then(|| PathBuf::from(recorded))
259}
260
261/// No registry to read. See the Windows implementation above.
262#[cfg(not(windows))]
263fn registry_steam_exe() -> Option<PathBuf> {
264 // Referenced so the names stay live off Windows and cannot rot unnoticed.
265 let _ = (STEAM_REGISTRY_KEY, STEAM_REGISTRY_VALUE);
266
267 None
268}
269
270/// Chooses the Steam program from host facts already gathered.
271///
272/// The entire precedence rule, taking the registry value and the install roots as
273/// parameters rather than reading them, so every branch — registry hit, stale
274/// registry, roots order, nothing found at all — is exercised on a Linux CI. That
275/// matters more here than usual: the bug this resolution exists to fix only
276/// reproduces on Windows, which cannot be tested directly.
277///
278/// The registry path is re-checked against the filesystem rather than trusted, so
279/// a stale entry left behind by a moved or uninstalled Steam falls through to the
280/// standard roots instead of yielding a path that cannot be spawned.
281fn resolve_steam_exe(registry: Option<PathBuf>, roots: &[PathBuf]) -> PathBuf {
282 registry
283 .filter(|path| path.is_file())
284 .or_else(|| first_existing(&steam_exe_candidates(roots)))
285 .unwrap_or_else(|| PathBuf::from(STEAM_FALLBACK))
286}
287
288/// The program that starts the Steam client on this machine.
289///
290/// Tries the registry, then the standard install roots, then falls back to a bare
291/// `STEAM_FALLBACK`. Resolution never fails: that fallback is exactly what this
292/// crate passed unconditionally before, so a machine this cannot resolve is left
293/// no worse off than it was.
294///
295/// [`crate::profile::launch::LaunchPlan::to_command`] calls this for
296/// [`crate::profile::launch::LaunchProgram::Steam`], so a caller spawning a
297/// computed plan gets it without asking. It is public for callers assembling a
298/// command themselves.
299pub fn steam_exe() -> PathBuf {
300 resolve_steam_exe(registry_steam_exe(), &install_roots())
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use tempfile::tempdir;
307
308 #[test]
309 fn suspect_prefix_matches_whole_components_only() {
310 assert!(suspect_prefix(Path::new(
311 "/tmp/t/d/valheim/steam-wrapper.sh"
312 )));
313 assert!(suspect_prefix(Path::new("/var/tmp/x")));
314 assert!(suspect_prefix(Path::new("/dev/shm/x")));
315 assert!(suspect_prefix(Path::new("/run/user/1000/x")));
316
317 assert!(!suspect_prefix(Path::new("/home/u/.local/share/vmm")));
318 assert!(!suspect_prefix(Path::new("/mnt/games/vmm")));
319
320 // A string-prefix check would wrongly flag these: neither is under /tmp.
321 assert!(!suspect_prefix(Path::new("/tmpfoo/x")));
322 assert!(!suspect_prefix(Path::new("/var/tmpfoo/x")));
323
324 // `/tmp` itself, and a relative path, are handled without panicking.
325 assert!(suspect_prefix(Path::new("/tmp")));
326 assert!(!suspect_prefix(Path::new("relative/path")));
327 }
328
329 #[test]
330 fn visible_in_resolves_an_absolute_path_under_the_namespace_root() {
331 let namespace = tempdir().unwrap();
332 let root = namespace.path();
333
334 std::fs::create_dir_all(root.join("home/u/.local/share/vmm/valheim")).unwrap();
335 std::fs::write(
336 root.join("home/u/.local/share/vmm/valheim/steam-wrapper.sh"),
337 b"#!/bin/sh\n",
338 )
339 .unwrap();
340
341 assert!(visible_in(
342 root,
343 Path::new("/home/u/.local/share/vmm/valheim/steam-wrapper.sh")
344 ));
345
346 // The namespace has no `/tmp` at all, exactly like a sandboxed Steam's.
347 assert!(!visible_in(
348 root,
349 Path::new("/tmp/t/d/valheim/steam-wrapper.sh")
350 ));
351
352 // A directory counts as visible: the wrapper's own check on the target is
353 // `[ -d ]`, not `[ -f ]`.
354 assert!(visible_in(root, Path::new("/home/u/.local/share/vmm")));
355 }
356
357 /// The leading `/` has to be stripped: `Path::join` with an absolute path
358 /// discards the root it is joined onto, which would silently check the *host*
359 /// filesystem and call every path reachable.
360 #[test]
361 fn visible_in_does_not_fall_through_to_the_host_filesystem() {
362 let namespace = tempdir().unwrap();
363 let real = tempdir().unwrap();
364 let outside = real.path().join("wrapper.sh");
365
366 std::fs::write(&outside, b"#!/bin/sh\n").unwrap();
367
368 assert!(outside.exists(), "the file must exist on the host");
369 assert!(
370 !visible_in(namespace.path(), &outside),
371 "a host path must not be reported visible in a namespace that lacks it"
372 );
373 }
374
375 /// Whatever this machine's Steam state, a verdict is always produced and never
376 /// panics, and a home-directory path is never reported as proven unreachable.
377 #[test]
378 fn visibility_returns_a_verdict_without_panicking() {
379 let home = std::env::var("HOME").unwrap_or_else(|_| "/home".to_string());
380 let verdict = visibility(Path::new(&home));
381
382 assert!(
383 !matches!(verdict, Visibility::Unreachable { .. }),
384 "a home path should never be proven unreachable, got: {verdict:?}"
385 );
386 }
387
388 #[test]
389 fn steam_exe_candidates_names_steam_exe_under_each_root_in_order() {
390 let roots = vec![
391 PathBuf::from(r"C:\Program Files (x86)"),
392 PathBuf::from(r"C:\Program Files"),
393 ];
394
395 assert_eq!(
396 steam_exe_candidates(&roots),
397 vec![
398 PathBuf::from(r"C:\Program Files (x86)")
399 .join("Steam")
400 .join("steam.exe"),
401 PathBuf::from(r"C:\Program Files")
402 .join("Steam")
403 .join("steam.exe"),
404 ]
405 );
406
407 // No roots to search is not an error; it simply proposes nothing, which is
408 // what the caller sees on every non-Windows host.
409 assert!(steam_exe_candidates(&[]).is_empty());
410 }
411
412 #[test]
413 fn first_existing_returns_the_earliest_candidate_present_on_disk() {
414 let dir = tempdir().unwrap();
415 let root = dir.path();
416 let absent = root.join("absent.exe");
417 let present = root.join("present.exe");
418 let also_present = root.join("also_present.exe");
419
420 std::fs::write(&present, b"MZ").unwrap();
421 std::fs::write(&also_present, b"MZ").unwrap();
422
423 // Preference order decides, not disk order: the first *present* candidate
424 // wins even though an earlier one was looked for and missing.
425 assert_eq!(
426 first_existing(&[absent.clone(), present.clone(), also_present]),
427 Some(present)
428 );
429
430 assert_eq!(first_existing(&[absent]), None);
431
432 // A directory is not an executable, so it must not satisfy a candidate.
433 assert_eq!(first_existing(&[root.to_path_buf()]), None);
434 }
435
436 /// The precedence the Windows fix turns on, asserted end to end on a host that
437 /// has neither a registry nor a Steam. Each branch is reached by varying only
438 /// the two inputs.
439 #[test]
440 fn resolve_steam_exe_prefers_the_registry_then_the_roots_then_the_fallback() {
441 let dir = tempdir().unwrap();
442 let root = dir.path();
443
444 // A Steam relocated to another drive: only the registry knows about it, and
445 // it must win even when a standard root would also have resolved.
446 let relocated = root.join("D_drive").join("Steam").join("steam.exe");
447 let standard = root.join("ProgramFilesX86");
448 let secondary = root.join("ProgramFiles");
449
450 std::fs::create_dir_all(relocated.parent().unwrap()).unwrap();
451 std::fs::create_dir_all(standard.join("Steam")).unwrap();
452 std::fs::create_dir_all(secondary.join("Steam")).unwrap();
453 std::fs::write(&relocated, b"MZ").unwrap();
454 std::fs::write(standard.join("Steam").join("steam.exe"), b"MZ").unwrap();
455 std::fs::write(secondary.join("Steam").join("steam.exe"), b"MZ").unwrap();
456
457 let roots = vec![standard.clone(), secondary.clone()];
458
459 assert_eq!(
460 resolve_steam_exe(Some(relocated.clone()), &roots),
461 relocated,
462 "a registry-recorded Steam must win over a standard root"
463 );
464
465 assert_eq!(
466 resolve_steam_exe(None, &roots),
467 standard.join("Steam").join("steam.exe"),
468 "with no registry value the first root wins"
469 );
470
471 // A registry entry left behind by a moved or uninstalled Steam must not be
472 // handed back: spawning it would fail where the standard root would work.
473 assert_eq!(
474 resolve_steam_exe(Some(root.join("gone").join("steam.exe")), &roots),
475 standard.join("Steam").join("steam.exe"),
476 "a stale registry entry must fall through to the roots"
477 );
478
479 assert_eq!(
480 resolve_steam_exe(None, std::slice::from_ref(&secondary)),
481 secondary.join("Steam").join("steam.exe"),
482 "the second root resolves when it is the only one offered"
483 );
484
485 // Nothing resolvable is not an error. The bare name is what this crate always
486 // passed, and it still works wherever Steam is on PATH.
487 assert_eq!(
488 resolve_steam_exe(None, &[root.join("empty")]),
489 PathBuf::from("steam")
490 );
491 assert_eq!(resolve_steam_exe(None, &[]), PathBuf::from("steam"));
492 }
493
494 /// The whole resolution path stays compiled and exercised off Windows, so the
495 /// runtime guard is what keeps it inert there. `install_roots` is the only
496 /// route the environment takes into resolution, so a host that happens to
497 /// export `ProgramFiles` — Wine and some CI images do — cannot divert a launch
498 /// as long as this stays empty.
499 ///
500 /// Asserted by calling the shim rather than by setting the variable, because
501 /// the crate is `#![forbid(unsafe_code)]` and `std::env::set_var` is `unsafe`.
502 #[test]
503 fn resolution_is_inert_off_windows() {
504 if cfg!(windows) {
505 return;
506 }
507
508 assert!(
509 install_roots().is_empty(),
510 "no install roots may be proposed off Windows"
511 );
512 assert_eq!(
513 registry_steam_exe(),
514 None,
515 "there is no registry to read off Windows"
516 );
517 assert_eq!(
518 steam_exe(),
519 PathBuf::from("steam"),
520 "off Windows the bare program name is correct and must be preserved"
521 );
522 }
523
524 #[test]
525 fn only_a_proven_absence_refuses() {
526 assert_eq!(Visibility::Reachable.severity(), Severity::Ok);
527 assert_eq!(
528 Visibility::Unknown { suspect: false }.severity(),
529 Severity::Ok
530 );
531 assert_eq!(
532 Visibility::Unknown { suspect: true }.severity(),
533 Severity::Warn
534 );
535 assert_eq!(
536 Visibility::Unreachable { steam_pid: 1234 }.severity(),
537 Severity::Refuse
538 );
539 }
540}