Skip to main content

thunderstore_engine/
steam.rs

1//! Whether the running Steam client can resolve a path a caller hands it.
2//!
3//! Steam's launch options are a string the caller never opens itself: it names
4//! a wrapper script and a target directory, and *Steam* is what has to resolve
5//! them. On a sandboxed Steam those are resolved in a different mount
6//! namespace, so a path that exists perfectly well for the caller can be
7//! absent for Steam. When that happens nothing on the caller's side fails,
8//! since `steam -applaunch` returns 0 either way, and the only record is a
9//! line in Steam's own console log. The launch simply appears to do nothing.
10//!
11//! This module makes that failure loud *before* a launch, by asking the running
12//! Steam client's own mount namespace whether it can see the path.
13//!
14//! This is host inspection, shared here in the engine rather than kept private
15//! to one client, because every client on Linux hits sandboxed Steam (Flatpak,
16//! Snap, containerized), and the failure mode is a launch that appears to do
17//! nothing, with the only record in Steam's own console log. Sharing the probe
18//! is worth the impurity of reading the host machine.
19
20#![deny(missing_docs)]
21
22use std::path::{Path, PathBuf};
23
24/// Path prefixes a sandboxed Steam is known to replace with a private mount, used
25/// only when no running Steam client is available to ask directly.
26///
27/// These are per-namespace or per-boot scratch locations that no persistent game
28/// data belongs in regardless of Steam, so flagging them is honest even against an
29/// unsandboxed Steam that could in fact read them.
30const SUSPECT_PREFIXES: [&str; 4] = ["/tmp", "/var/tmp", "/dev/shm", "/run/user"];
31
32/// What was determined about Steam's ability to resolve a path.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Visibility {
35  /// Present in the running Steam client's mount namespace.
36  Reachable,
37  /// Proven absent from the running Steam client's mount namespace. A launch using
38  /// this path would fail inside Steam and report nowhere the caller can see.
39  Unreachable {
40    /// PID of the running Steam client whose mount namespace was inspected.
41    steam_pid: u32,
42  },
43  /// No running Steam client could be inspected, so nothing was proven. `suspect`
44  /// marks a path under a `SUSPECT_PREFIXES` entry, which is the shape of path
45  /// that fails once Steam *is* sandboxed.
46  Unknown {
47    /// Whether the path sits under a prefix a sandboxed Steam is known to
48    /// replace with a private mount.
49    suspect: bool,
50  },
51}
52
53/// How a caller should treat a [`Visibility`] verdict.
54///
55/// The mapping lives here so every client agrees on refuse-versus-warn; the
56/// wording of each refusal or warning is each client's own.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Severity {
59  /// Nothing to report.
60  Ok,
61  /// Report a warning but proceed: the suspicion is unconfirmed.
62  Warn,
63  /// Refuse: a live Steam client was inspected and genuinely lacks the path.
64  Refuse,
65}
66
67impl Visibility {
68  /// How a caller should treat this verdict.
69  ///
70  /// Only a proven absence refuses. An unproven suspicion warns, because
71  /// reporting it as a failure would be a claim the evidence does not support.
72  pub fn severity(&self) -> Severity {
73    match self {
74      Self::Reachable | Self::Unknown { suspect: false } => Severity::Ok,
75      Self::Unknown { suspect: true } => Severity::Warn,
76      Self::Unreachable { .. } => Severity::Refuse,
77    }
78  }
79}
80
81/// Whether `path` starts with a `SUSPECT_PREFIXES` entry.
82///
83/// Compared component-wise rather than as a string prefix, so `/tmpfoo` is not
84/// mistaken for something under `/tmp`.
85pub fn suspect_prefix(path: &Path) -> bool {
86  SUSPECT_PREFIXES
87    .iter()
88    .any(|prefix| starts_with_components(path, Path::new(prefix)))
89}
90
91/// Whether every component of `prefix`, in order, opens `path`.
92fn starts_with_components(path: &Path, prefix: &Path) -> bool {
93  let mut components = path.components();
94
95  for expected in prefix.components() {
96    match components.next() {
97      Some(actual) if actual == expected => {}
98      _ => return false,
99    }
100  }
101
102  true
103}
104
105/// Whether `path` resolves inside the filesystem rooted at `namespace_root`.
106///
107/// `namespace_root` is `/proc/<pid>/root` in real use, which the kernel resolves
108/// against that process's root, including absolute symlinks encountered along the
109/// way, so this genuinely answers "can that process open this path", not merely
110/// "does a similarly named file exist here". Taking the root as a parameter is also
111/// what makes the resolution testable against an ordinary directory tree.
112///
113/// `path`'s leading `/` is stripped before joining, since [`Path::join`] with an
114/// absolute path discards the root it is joined onto.
115fn visible_in(namespace_root: &Path, path: &Path) -> bool {
116  let relative = path.strip_prefix("/").unwrap_or(path);
117
118  namespace_root.join(relative).exists()
119}
120
121/// The running Steam client's pid, as Steam itself records it.
122///
123/// Read from `~/.steam/steam.pid`, which the client writes on startup, and
124/// accepted only if that pid currently belongs to a process named `steam`. A pid
125/// file outlives the process that wrote it, and a recycled pid would otherwise
126/// have us inspecting an unrelated namespace and reporting nonsense about it.
127pub fn steam_client_pid() -> Option<u32> {
128  let home = std::env::var_os("HOME")?;
129  let pid_file = PathBuf::from(home).join(".steam").join("steam.pid");
130  let pid: u32 = std::fs::read_to_string(pid_file)
131    .ok()?
132    .trim()
133    .parse()
134    .ok()?;
135
136  let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?;
137
138  (comm.trim() == "steam").then_some(pid)
139}
140
141/// Whether the running Steam client can resolve `path`.
142///
143/// Returns [`Visibility::Unknown`] whenever no Steam client can be inspected, be
144/// that Steam not running, a platform without `/proc`, or an unreadable pid file,
145/// because an unproven suspicion must not be reported as a proven failure.
146pub fn visibility(path: &Path) -> Visibility {
147  let Some(steam_pid) = steam_client_pid() else {
148    return Visibility::Unknown {
149      suspect: suspect_prefix(path),
150    };
151  };
152
153  let namespace_root = PathBuf::from(format!("/proc/{steam_pid}/root"));
154
155  // An unreadable namespace root proves nothing either way; only a readable one
156  // that lacks the path is evidence.
157  if !namespace_root.is_dir() {
158    return Visibility::Unknown {
159      suspect: suspect_prefix(path),
160    };
161  }
162
163  if visible_in(&namespace_root, path) {
164    return Visibility::Reachable;
165  }
166
167  Visibility::Unreachable { steam_pid }
168}
169
170#[cfg(test)]
171mod tests {
172  use super::*;
173  use tempfile::tempdir;
174
175  #[test]
176  fn suspect_prefix_matches_whole_components_only() {
177    assert!(suspect_prefix(Path::new(
178      "/tmp/t/d/valheim/steam-wrapper.sh"
179    )));
180    assert!(suspect_prefix(Path::new("/var/tmp/x")));
181    assert!(suspect_prefix(Path::new("/dev/shm/x")));
182    assert!(suspect_prefix(Path::new("/run/user/1000/x")));
183
184    assert!(!suspect_prefix(Path::new("/home/u/.local/share/vmm")));
185    assert!(!suspect_prefix(Path::new("/mnt/games/vmm")));
186
187    // A string-prefix check would wrongly flag these: neither is under /tmp.
188    assert!(!suspect_prefix(Path::new("/tmpfoo/x")));
189    assert!(!suspect_prefix(Path::new("/var/tmpfoo/x")));
190
191    // `/tmp` itself, and a relative path, are handled without panicking.
192    assert!(suspect_prefix(Path::new("/tmp")));
193    assert!(!suspect_prefix(Path::new("relative/path")));
194  }
195
196  #[test]
197  fn visible_in_resolves_an_absolute_path_under_the_namespace_root() {
198    let namespace = tempdir().unwrap();
199    let root = namespace.path();
200
201    std::fs::create_dir_all(root.join("home/u/.local/share/vmm/valheim")).unwrap();
202    std::fs::write(
203      root.join("home/u/.local/share/vmm/valheim/steam-wrapper.sh"),
204      b"#!/bin/sh\n",
205    )
206    .unwrap();
207
208    assert!(visible_in(
209      root,
210      Path::new("/home/u/.local/share/vmm/valheim/steam-wrapper.sh")
211    ));
212
213    // The namespace has no `/tmp` at all, exactly like a sandboxed Steam's.
214    assert!(!visible_in(
215      root,
216      Path::new("/tmp/t/d/valheim/steam-wrapper.sh")
217    ));
218
219    // A directory counts as visible: the wrapper's own check on the target is
220    // `[ -d ]`, not `[ -f ]`.
221    assert!(visible_in(root, Path::new("/home/u/.local/share/vmm")));
222  }
223
224  /// The leading `/` has to be stripped: `Path::join` with an absolute path
225  /// discards the root it is joined onto, which would silently check the *host*
226  /// filesystem and call every path reachable.
227  #[test]
228  fn visible_in_does_not_fall_through_to_the_host_filesystem() {
229    let namespace = tempdir().unwrap();
230    let real = tempdir().unwrap();
231    let outside = real.path().join("wrapper.sh");
232
233    std::fs::write(&outside, b"#!/bin/sh\n").unwrap();
234
235    assert!(outside.exists(), "the file must exist on the host");
236    assert!(
237      !visible_in(namespace.path(), &outside),
238      "a host path must not be reported visible in a namespace that lacks it"
239    );
240  }
241
242  /// Whatever this machine's Steam state, a verdict is always produced and never
243  /// panics, and a home-directory path is never reported as proven unreachable.
244  #[test]
245  fn visibility_returns_a_verdict_without_panicking() {
246    let home = std::env::var("HOME").unwrap_or_else(|_| "/home".to_string());
247    let verdict = visibility(Path::new(&home));
248
249    assert!(
250      !matches!(verdict, Visibility::Unreachable { .. }),
251      "a home path should never be proven unreachable, got: {verdict:?}"
252    );
253  }
254
255  #[test]
256  fn only_a_proven_absence_refuses() {
257    assert_eq!(Visibility::Reachable.severity(), Severity::Ok);
258    assert_eq!(
259      Visibility::Unknown { suspect: false }.severity(),
260      Severity::Ok
261    );
262    assert_eq!(
263      Visibility::Unknown { suspect: true }.severity(),
264      Severity::Warn
265    );
266    assert_eq!(
267      Visibility::Unreachable { steam_pid: 1234 }.severity(),
268      Severity::Refuse
269    );
270  }
271}