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}