//! Finding a launched game's processes on Linux, from the signals its store gave us //! ([`crate::library::DetectSpec`]). //! //! Everything here reads `/proc` and nothing else — no ptrace, no injection, no handles kept. The //! host only ever looks at processes owned by its **own uid**, and only ever *reads* metadata it //! already has permission to see. //! //! Two rules keep this honest, and both matter more than the matching itself: //! //! 1. **Never adopt a process that predates the launch.** A player may already have the game open //! when a session starts; treating that instance as "this session's game" would let a session end //! kill a process it never started. Candidates are filtered by start time //! ([`Scanner::find`]'s `min_start`). //! 2. **Never trust a bare pid.** Pids are recycled, and a lease can outlive its game by a grace //! window, so every remembered process carries its start time and is re-verified against it //! ([`Scanner::alive`]) before it is ever counted as running — or signalled. use crate::library::DetectSpec; use std::path::{Path, PathBuf}; /// A process the matcher adopted: its pid plus the start time that pins that pid to *this* /// process, so a recycled pid can never be mistaken for it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ProcRef { pub pid: u32, /// `/proc//stat` field 22 — start time in clock ticks since boot. Unique per (pid, boot). pub start_ticks: u64, } /// Largest `cmdline`/`environ` blob we will read from a single process. Both are bounded by `ARG_MAX` /// in practice; the cap exists so a pathological process can't make the host allocate without bound /// during a once-a-second scan. const MAX_PROC_BLOB: u64 = 512 * 1024; /// Tolerance on the "started after the launch" test, in seconds. The launch timestamp is taken just /// before the spawn, but clock-tick granularity (`starttime` is quantized to ~10 ms) and a launcher /// that raced ahead of our own bookkeeping mean an exact comparison would occasionally reject the /// real game. Two seconds is far below the time any launcher takes to bring a game up, so it cannot /// let a *pre-existing* instance through. const START_SLACK_SECS: f64 = 2.0; /// A `/proc` reader. Parameterized on its root, uid filter, and clock rate so the matching logic can /// be unit-tested against a fixture tree instead of the live system. pub struct Scanner { root: PathBuf, /// Only consider processes owned by this uid. `None` (tests only) considers all. uid: Option, ticks_per_sec: f64, } impl Scanner { /// The live system: `/proc`, this host process's own uid, the kernel's clock rate. pub fn system() -> Self { // SAFETY: a parameterless POSIX call that always succeeds and touches no memory. let uid = unsafe { libc::getuid() }; // SAFETY: `sysconf` reads a static system limit by name; no memory of ours is involved, and a // non-positive answer (which the caller below handles) is its documented failure signal. let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; Self { root: PathBuf::from("/proc"), uid: Some(uid), // A non-positive sysconf answer would poison every start-time comparison; fall back to // the universal Linux value rather than divide by nonsense. ticks_per_sec: if ticks > 0 { ticks as f64 } else { 100.0 }, } } /// Seconds since boot, on the same timeline as a process's start time. `None` when `/proc/uptime` /// can't be read (the caller then skips start-time filtering rather than rejecting everything). pub fn uptime(&self) -> Option { let text = std::fs::read_to_string(self.root.join("uptime")).ok()?; text.split_whitespace().next()?.parse().ok() } /// Every process matching any of `spec`'s signals, restricted to those that started at or after /// `min_start` (seconds since boot; `None` disables the filter). /// /// The signals are a union: a Steam appid, an env marker, an exact executable, or an install /// directory each independently qualify a process. That is deliberate — the store with the /// sharpest signal wins, but a store that only knows its install directory is still covered. pub fn find(&self, spec: &DetectSpec, min_start: Option) -> Vec { if spec.is_empty() { return Vec::new(); } // Resolve symlinks in the install dir once per scan: a game reached through a symlinked // library folder would otherwise never prefix-match its own canonical image path. let dir = spec .install_dir .as_deref() .map(|d| d.canonicalize().unwrap_or_else(|_| d.to_path_buf())); let exe = spec .exe .as_deref() .map(|e| e.canonicalize().unwrap_or_else(|_| e.to_path_buf())); let steam_tok = spec.steam_appid.map(|id| format!("AppId={id}")); let Ok(entries) = std::fs::read_dir(&self.root) else { return Vec::new(); }; let mut out = Vec::new(); for e in entries.flatten() { let name = e.file_name(); let Some(pid) = name.to_str().and_then(|s| s.parse::().ok()) else { continue; // not a pid dir }; let dir_path = e.path(); if let Some(uid) = self.uid { let owned = std::fs::metadata(&dir_path) .map(|m| { use std::os::unix::fs::MetadataExt; m.uid() == uid }) .unwrap_or(false); if !owned { continue; } } let Some(start_ticks) = self.start_ticks(&dir_path) else { continue; // vanished mid-scan, or an unparseable stat }; if let Some(min) = min_start { let started = start_ticks as f64 / self.ticks_per_sec; if started + START_SLACK_SECS < min { continue; // predates this launch — never ours (rule 1) } } if self.matches( &dir_path, spec, steam_tok.as_deref(), exe.as_deref(), dir.as_deref(), ) { out.push(ProcRef { pid, start_ticks }); } } out } /// Which of `procs` are still the same live processes — pid present **and** start time unchanged, /// so a recycled pid is never reported alive (rule 2). pub fn alive(&self, procs: &[ProcRef]) -> Vec { procs .iter() .copied() .filter(|p| self.start_ticks(&self.root.join(p.pid.to_string())) == Some(p.start_ticks)) .collect() } /// Does this process match any of the spec's signals? fn matches( &self, dir_path: &Path, spec: &DetectSpec, steam_tok: Option<&str>, exe: Option<&Path>, install_dir: Option<&Path>, ) -> bool { // The process's own image, resolved through /proc//exe. Absent for a kernel thread or a // process whose binary was replaced. let image = std::fs::read_link(dir_path.join("exe")).ok(); if let Some(want) = exe { if image.as_deref() == Some(want) { return true; } } if let Some(dir) = install_dir { if image.as_deref().is_some_and(|i| i.starts_with(dir)) { return true; } } // The command line covers what the image can't: a Proton/Wine title's image is the *runtime* // (`…/proton`, `wine64-preloader`), and the game only appears as an argument; Steam's launch // reaper is likewise identified by its argv, not its binary. let cmdline = read_capped(&dir_path.join("cmdline")); if let Some(cmdline) = cmdline.as_deref() { if let Some(tok) = steam_tok { // Both tokens together, exact-matched, so `AppId=57` never satisfies appid 570 and // Steam's own (non-reaper) helper steps aren't mistaken for the game. let mut launch = false; let mut appid = false; for arg in cmdline.split(|&b| b == 0) { if arg == b"SteamLaunch" { launch = true; } else if arg == tok.as_bytes() { appid = true; } } if launch && appid { return true; } } if let Some(dir) = install_dir { let needle = dir.as_os_str().as_encoded_bytes(); for arg in cmdline.split(|&b| b == 0) { if arg.starts_with(needle) { return true; } } } if let Some(want) = exe { let needle = want.as_os_str().as_encoded_bytes(); if cmdline.split(|&b| b == 0).any(|arg| arg == needle) { return true; } } } // Env markers last: reading another process's environment is the most invasive of these // reads, so it only happens for a spec that actually asked for one. The contents are matched // and discarded — never logged. if let Some(marker) = &spec.env_marker { if let Some(env) = read_capped(&dir_path.join("environ")) { let want: Vec = match &marker.value { Some(v) => format!("{}={v}", marker.key).into_bytes(), None => format!("{}=", marker.key).into_bytes(), }; let hit = env.split(|&b| b == 0).any(|kv| match marker.value { // An exact `KEY=VALUE` entry. Some(_) => kv == want.as_slice(), // Presence of the key with any value. None => kv.starts_with(want.as_slice()), }); if hit { return true; } } } false } /// `/proc//stat` field 22 (`starttime`). The `comm` field is parenthesized and may itself /// contain spaces and parentheses, so fields are counted from the **last** `)`: the token right /// after it is field 3 (`state`), which puts `starttime` at index 19 of the remainder. fn start_ticks(&self, dir_path: &Path) -> Option { let stat = std::fs::read_to_string(dir_path.join("stat")).ok()?; let tail = &stat[stat.rfind(')')? + 1..]; tail.split_whitespace().nth(19)?.parse().ok() } } /// Read a `/proc` blob with a hard size cap (see [`MAX_PROC_BLOB`]). `None` when the process vanished /// or the file is unreadable — both routine during a scan. fn read_capped(path: &Path) -> Option> { use std::io::Read; let file = std::fs::File::open(path).ok()?; let mut buf = Vec::new(); file.take(MAX_PROC_BLOB).read_to_end(&mut buf).ok()?; Some(buf) } #[cfg(test)] mod tests { use super::*; use crate::library::DetectSpec; /// Build a fake `/proc` tree. Each process gets a `stat` (with a deliberately nasty `comm`), and /// optionally a `cmdline`, an `environ`, and an `exe` symlink. struct FakeProc { pid: u32, start_ticks: u64, exe: Option, cmdline: Vec<&'static str>, environ: Vec<&'static str>, } impl FakeProc { fn new(pid: u32, start_ticks: u64) -> Self { Self { pid, start_ticks, exe: None, cmdline: Vec::new(), environ: Vec::new(), } } fn exe(mut self, p: impl Into) -> Self { self.exe = Some(p.into()); self } fn cmdline(mut self, args: &[&'static str]) -> Self { self.cmdline = args.to_vec(); self } fn environ(mut self, kvs: &[&'static str]) -> Self { self.environ = kvs.to_vec(); self } } fn fake_proc_root(uptime: f64, procs: &[FakeProc]) -> tempfile::TempDir { let td = tempfile::tempdir().expect("tempdir"); std::fs::write(td.path().join("uptime"), format!("{uptime} 1000.0\n")).unwrap(); // Non-pid entries must be skipped, not parsed. std::fs::create_dir_all(td.path().join("self")).unwrap(); std::fs::write(td.path().join("cmdline"), b"").unwrap(); for p in procs { let dir = td.path().join(p.pid.to_string()); std::fs::create_dir_all(&dir).unwrap(); // `stat` is `pid (comm) state ppid … starttime …`, with starttime as field 22. Counting // from the last ')', index 0 is `state` (field 3), so starttime lands at index 19. The // comm is hostile on purpose — a space and a close-paren inside the parens, which is // exactly what naive field-splitting gets wrong. let mut tail = vec!["0".to_string(); 20]; tail[0] = "S".to_string(); // field 3 tail[19] = p.start_ticks.to_string(); // field 22 std::fs::write( dir.join("stat"), format!("{} (evil ) name) {}\n", p.pid, tail.join(" ")), ) .unwrap(); if !p.cmdline.is_empty() { let mut blob = Vec::new(); for a in &p.cmdline { blob.extend_from_slice(a.as_bytes()); blob.push(0); } std::fs::write(dir.join("cmdline"), blob).unwrap(); } if !p.environ.is_empty() { let mut blob = Vec::new(); for a in &p.environ { blob.extend_from_slice(a.as_bytes()); blob.push(0); } std::fs::write(dir.join("environ"), blob).unwrap(); } if let Some(exe) = &p.exe { std::os::unix::fs::symlink(exe, dir.join("exe")).unwrap(); } } td } fn scanner(root: &Path) -> Scanner { Scanner { root: root.to_path_buf(), uid: None, // the fixture's owner is the test user; don't couple the test to it ticks_per_sec: 100.0, } } fn pids(mut v: Vec) -> Vec { v.sort_by_key(|p| p.pid); v.into_iter().map(|p| p.pid).collect() } #[test] fn empty_spec_matches_nothing() { let td = fake_proc_root(100.0, &[FakeProc::new(1, 100).exe("/games/x/run")]); let s = scanner(td.path()); assert!(s.find(&DetectSpec::default(), None).is_empty()); } #[test] fn matches_exact_exe_and_install_dir() { let td = fake_proc_root( 1000.0, &[ FakeProc::new(10, 50_000).exe("/games/hades/Hades"), FakeProc::new(11, 50_000).exe("/games/hades/tools/crash.bin"), FakeProc::new(12, 50_000).exe("/usr/bin/firefox"), ], ); let s = scanner(td.path()); // Exact exe → only that process. assert_eq!( pids(s.find(&DetectSpec::exe("/games/hades/Hades"), None)), vec![10] ); // Install dir → every process running out of the game's tree, but nothing outside it. assert_eq!( pids(s.find(&DetectSpec::dir("/games/hades"), None)), vec![10, 11] ); } #[test] fn matches_proton_style_game_only_in_the_cmdline() { // The image is the Proton runtime; the game appears only as an argument. let td = fake_proc_root( 1000.0, &[FakeProc::new(20, 50_000) .exe("/steam/runtime/proton") .cmdline(&["proton", "waitforexitandrun", "/games/elden/eldenring.exe"])], ); let s = scanner(td.path()); assert_eq!( pids(s.find(&DetectSpec::dir("/games/elden"), None)), vec![20] ); } #[test] fn matches_steam_reaper_exactly() { let td = fake_proc_root( 1000.0, &[ FakeProc::new(30, 50_000).cmdline(&[ "reaper", "SteamLaunch", "AppId=570", "--", "dota", ]), // A different appid that shares a prefix must NOT match (AppId=57 vs 570). FakeProc::new(31, 50_000).cmdline(&[ "reaper", "SteamLaunch", "AppId=57", "--", "other", ]), // `SteamLaunch` without the appid, and the appid without `SteamLaunch`, are both no. FakeProc::new(32, 50_000).cmdline(&["reaper", "SteamLaunch", "--", "shader"]), FakeProc::new(33, 50_000).cmdline(&["something", "AppId=570"]), ], ); let s = scanner(td.path()); assert_eq!(pids(s.find(&DetectSpec::steam(570), None)), vec![30]); assert_eq!(pids(s.find(&DetectSpec::steam(57), None)), vec![31]); } #[test] fn matches_env_marker_by_exact_value_or_presence() { let td = fake_proc_root( 1000.0, &[ FakeProc::new(40, 50_000).environ(&["HOME=/home/u", "HEROIC_APP_NAME=Quail"]), FakeProc::new(41, 50_000).environ(&["HEROIC_APP_NAME=OtherGame"]), FakeProc::new(42, 50_000).environ(&["HOME=/home/u"]), ], ); let s = scanner(td.path()); let exact = DetectSpec::default().with_env("HEROIC_APP_NAME", Some("Quail".to_string())); assert_eq!(pids(s.find(&exact, None)), vec![40]); // Presence-only matches any value, but still nothing that lacks the key. let any = DetectSpec::default().with_env("HEROIC_APP_NAME", None); assert_eq!(pids(s.find(&any, None)), vec![40, 41]); } #[test] fn never_adopts_a_process_that_predates_the_launch() { // Uptime 1000 s; the launch happened at t=900. pid 50 started at t=500 (a copy the player // already had open), pid 51 at t=950 (ours). let td = fake_proc_root( 1000.0, &[ FakeProc::new(50, 50_000).exe("/games/hades/Hades"), FakeProc::new(51, 95_000).exe("/games/hades/Hades"), ], ); let s = scanner(td.path()); let spec = DetectSpec::dir("/games/hades"); assert_eq!(pids(s.find(&spec, Some(900.0))), vec![51]); // Without the filter both are visible — proving the filter is what excluded it. assert_eq!(pids(s.find(&spec, None)), vec![50, 51]); } #[test] fn start_slack_tolerates_tick_granularity() { // Started a hair (0.5 s) BEFORE the recorded launch instant: still ours. let td = fake_proc_root(1000.0, &[FakeProc::new(60, 89_950).exe("/games/x/run")]); let s = scanner(td.path()); assert_eq!( pids(s.find(&DetectSpec::dir("/games/x"), Some(900.0))), vec![60] ); // A full minute early is not. assert!(s.find(&DetectSpec::dir("/games/x"), Some(960.0)).is_empty()); } #[test] fn alive_rejects_a_recycled_pid() { let td = fake_proc_root(1000.0, &[FakeProc::new(70, 50_000).exe("/games/x/run")]); let s = scanner(td.path()); let same = ProcRef { pid: 70, start_ticks: 50_000, }; let recycled = ProcRef { pid: 70, start_ticks: 99_999, }; let gone = ProcRef { pid: 71, start_ticks: 50_000, }; assert_eq!(s.alive(&[same]), vec![same]); assert!(s.alive(&[recycled]).is_empty()); assert!(s.alive(&[gone]).is_empty()); } #[test] fn parses_uptime_and_hostile_comm() { let td = fake_proc_root(4321.5, &[FakeProc::new(80, 1234)]); let s = scanner(td.path()); assert_eq!(s.uptime(), Some(4321.5)); // The comm field contains a space and a ')' — start time must still parse. assert_eq!(s.start_ticks(&td.path().join("80")), Some(1234)); } }