Skip to main content

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}