feat(session): end-game/end-session lifetime on Windows
Phase 2. The lease, the settings, the events and the status surface were already platform-neutral; what Windows lacked was a way to see processes and a way to ask one to close. `procscan` splits per-OS behind one contract. The Windows matcher is a Toolhelp snapshot plus each process's full image path (`QueryFullProcessImageNameW`), with creation times from `GetProcessTimes` enforcing the two rules the module exists for: never adopt a process that predates the launch, never trust a bare pid. Both matter more here than on Linux — the host is SYSTEM, so it can see and signal everything, and Windows recycles pids briskly. Path matching is case-insensitive and separator-aware, and normalizes the `\\?\` prefix `canonicalize` adds, without which a store-derived path would never compare equal to a live image path. There is no reaper argv and no readable environment on Windows, so a spec carrying only a Steam appid or an env marker matches nothing rather than falling through to an empty predicate. Steam's appid instead feeds a **veto**: its per-app `Running` flag in the user's hive can't be trusted to say a game IS running (Steam sets it around updates too, and leaves it stale after a crash), but it is exactly right for refusing to declare one gone. If the matcher can't see a game whose exe sits outside its manifest's install dir, ending the session would be a false positive the player feels immediately; honoring the veto only ever leaves a stream up. Termination asks before it insists, which on Windows needs a detail that fails silently if missed: `EnumWindows` only sees the calling thread's desktop, and the host's is session 0, which holds none of the user's windows. So the terminating thread binds to the input desktop first (the pattern `pf-inject`'s `sendinput.rs` uses), posts `WM_CLOSE` to the game's visible top-level windows, waits, and only then calls `TerminateProcess` on freshly re-verified pids. Without the desktop bind the polite pass finds nothing and every game dies unsaved. Job Objects, planned as WP2.3, are deferred with the reasoning in the design doc: every Windows launch either hands off through a launcher or the shell — where a job would wrap a shim that exits immediately — or is a direct exe whose path we already know, and wiring one in would touch the `CreateProcessAsUserW` token path the UAC and secure-desktop work depends on. Also: the watcher's steady-state poll now re-verifies known pids and only re-scans the whole table once they all vanish (which still catches a game that re-execs). On Windows a full scan is an `OpenProcess` per process on the box, so this is the difference between a negligible and a noticeable poll. Gates: Linux .21 check/clippy/fmt/293 tests green; Windows .133 check + clippy --all-targets clean, and the 4 procscan tests pass — including the live one that finds the test process in the real process table and rejects a wrong creation time. On-glass Windows (Steam/Epic/GOG/Xbox titles, the unsaved-progress check) still owed; it needs a box with games on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,558 @@
|
||||
//! The Linux matcher: `/proc`.
|
||||
//!
|
||||
//! Contract, rules, and the shared vocabulary live in [`super`]; this module is only the reading of
|
||||
//! `/proc` — and the parsing that gets wrong more often than it looks (`stat`'s `comm` field can
|
||||
//! contain spaces *and* parentheses, so fields must be counted from the last `)`).
|
||||
|
||||
use super::{ProcRef, START_SLACK_SECS};
|
||||
use crate::library::DetectSpec;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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<u32>,
|
||||
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 — the Linux process-start timeline (see [`super::launch_stamp`]). `None`
|
||||
/// when `/proc/uptime` can't be read, which disables start-time filtering rather than rejecting
|
||||
/// every candidate.
|
||||
pub fn now_stamp(&self) -> Option<f64> {
|
||||
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<f64>) -> Vec<ProcRef> {
|
||||
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::<u32>().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: 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<ProcRef> {
|
||||
procs
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|p| self.start_ticks(&self.root.join(p.pid.to_string())) == Some(p.start))
|
||||
.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/<pid>/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 {
|
||||
// Require a path separator after the directory, so an install dir of `/games/x` is
|
||||
// not satisfied by an unrelated `/games/xyz/…` argument. (The image-path check above
|
||||
// needs no such care — `Path::starts_with` already compares whole components.)
|
||||
let needle = dir.as_os_str().as_encoded_bytes();
|
||||
let under_dir = |arg: &[u8]| {
|
||||
arg.strip_prefix(needle)
|
||||
.is_some_and(|rest| rest.first() == Some(&b'/'))
|
||||
};
|
||||
if cmdline.split(|&b| b == 0).any(under_dir) {
|
||||
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<u8> = 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/<pid>/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<u64> {
|
||||
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<Vec<u8>> {
|
||||
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: u64,
|
||||
exe: Option<PathBuf>,
|
||||
cmdline: Vec<&'static str>,
|
||||
environ: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl FakeProc {
|
||||
fn new(pid: u32, start: u64) -> Self {
|
||||
Self {
|
||||
pid,
|
||||
start,
|
||||
exe: None,
|
||||
cmdline: Vec::new(),
|
||||
environ: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn exe(mut self, p: impl Into<PathBuf>) -> 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.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<ProcRef>) -> Vec<u32> {
|
||||
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 install_dir_does_not_match_a_sibling_with_the_same_prefix() {
|
||||
// `/games/x` must not adopt a process running out of `/games/xyz` — a real hazard when one
|
||||
// library folder's name is a prefix of another's.
|
||||
let td = fake_proc_root(
|
||||
1000.0,
|
||||
&[
|
||||
FakeProc::new(90, 50_000).exe("/games/xyz/other"),
|
||||
FakeProc::new(91, 50_000).cmdline(&["wrapper", "/games/xyz/other"]),
|
||||
],
|
||||
);
|
||||
let s = scanner(td.path());
|
||||
assert!(s.find(&DetectSpec::dir("/games/x"), None).is_empty());
|
||||
// The genuine directory still matches, by image path and by argument.
|
||||
assert_eq!(
|
||||
pids(s.find(&DetectSpec::dir("/games/xyz"), None)),
|
||||
vec![90, 91]
|
||||
);
|
||||
}
|
||||
|
||||
#[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: 50_000,
|
||||
};
|
||||
let recycled = ProcRef {
|
||||
pid: 70,
|
||||
start: 99_999,
|
||||
};
|
||||
let gone = ProcRef {
|
||||
pid: 71,
|
||||
start: 50_000,
|
||||
};
|
||||
assert_eq!(s.alive(&[same]), vec![same]);
|
||||
assert!(s.alive(&[recycled]).is_empty());
|
||||
assert!(s.alive(&[gone]).is_empty());
|
||||
}
|
||||
|
||||
/// Everything above runs against a fixture tree, which proves the parsing but not that the
|
||||
/// parsing describes this kernel. This one scans the **real** `/proc` for a process it just
|
||||
/// started — the only version of this test that would notice `/proc/<pid>/stat` field ordering,
|
||||
/// the uid check, or the uptime clock being wrong on a real box.
|
||||
#[test]
|
||||
fn finds_a_real_process_it_just_started() {
|
||||
// A real game's *binary* lives under its install dir, which is what makes the install-dir
|
||||
// recipe work. So the stand-in must too: copy a long-running binary into the directory and run
|
||||
// it from there. (A wrapper script that `exec`s something outside the directory would leave no
|
||||
// trace of the directory in either the image path or the command line — worth knowing, and the
|
||||
// reason a copied binary is the honest fixture here.)
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let game = td.path().join("game");
|
||||
std::fs::copy("/bin/sleep", &game).expect("copy a stand-in game binary");
|
||||
let s = Scanner::system();
|
||||
let before = s.now_stamp().expect("real /proc/uptime is readable");
|
||||
|
||||
let mut child = std::process::Command::new(&game)
|
||||
.arg("20")
|
||||
.spawn()
|
||||
.expect("spawn the fake game");
|
||||
// Give it a moment to be visible in /proc.
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
|
||||
let found = s.find(&DetectSpec::dir(td.path()), Some(before));
|
||||
assert!(
|
||||
found.iter().any(|p| p.pid == child.id()),
|
||||
"scanning the real /proc did not find pid {} under {}; found {found:?}",
|
||||
child.id(),
|
||||
td.path().display()
|
||||
);
|
||||
// The same process is still alive when re-verified by (pid, start time).
|
||||
assert!(!s.alive(&found).is_empty());
|
||||
// The exact-exe recipe finds it too, on the same live process.
|
||||
assert!(s
|
||||
.find(&DetectSpec::exe(&game), Some(before))
|
||||
.iter()
|
||||
.any(|p| p.pid == child.id()));
|
||||
|
||||
// A launch reference AFTER this process started must exclude it — the rule that keeps a
|
||||
// pre-existing copy of a game from being adopted, checked against a real start time.
|
||||
let after = s.now_stamp().unwrap() + 60.0;
|
||||
assert!(s.find(&DetectSpec::dir(td.path()), Some(after)).is_empty());
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
// Once it is gone and reaped, neither the scan nor the liveness re-check sees it.
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
assert!(s.find(&DetectSpec::dir(td.path()), Some(before)).is_empty());
|
||||
assert!(s.alive(&found).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.now_stamp(), 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! The Windows matcher: a Toolhelp snapshot plus each process's full image path.
|
||||
//!
|
||||
//! Contract and rules live in [`super`]. Two differences from the Linux side shape everything here:
|
||||
//!
|
||||
//! * **The host is SYSTEM**, so it can open essentially any process. That makes rule 1 (never adopt a
|
||||
//! process that predates the launch) load-bearing rather than a nicety — without it a scan would
|
||||
//! happily adopt a copy of the game the player started an hour ago, and a session ending could kill
|
||||
//! it.
|
||||
//! * **There is no launch reaper and no readable environment.** Steam's `SteamLaunch AppId=` argv
|
||||
//! trick is Linux-only, and reading another process's environment block on Windows needs
|
||||
//! `NtQueryInformationProcess` against an undocumented layout — not something to build a
|
||||
//! game-killing decision on. So the recipe is the image path: exactly the game's executable, or any
|
||||
//! executable under its install directory. Every Windows store the library scans reports one or
|
||||
//! both ([`crate::library::DetectSpec`]).
|
||||
|
||||
use super::{ProcRef, START_SLACK_SECS};
|
||||
use crate::library::DetectSpec;
|
||||
use std::path::{Path, PathBuf};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows::Win32::System::Threading::{
|
||||
GetProcessTimes, OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_FORMAT,
|
||||
PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
|
||||
/// 100-nanosecond `FILETIME` ticks per second — the unit every Win32 time API here reports in.
|
||||
const FILETIME_TICKS_PER_SEC: f64 = 10_000_000.0;
|
||||
|
||||
/// Image-path buffer, in UTF-16 units. Deliberately well past `MAX_PATH` (260): a game installed under
|
||||
/// a deep library folder can exceed it, and a truncated path would silently fail to match. A path
|
||||
/// longer than this makes `QueryFullProcessImageNameW` fail, which skips that process — the same
|
||||
/// outcome as any other unreadable one.
|
||||
const IMAGE_PATH_MAX: usize = 4096;
|
||||
|
||||
/// A process scanner over the live system. Unit (unlike the Linux one, which is parameterized for its
|
||||
/// fixture tests): there is no way to hand Win32 a fake process table, so the Windows matching logic
|
||||
/// is tested through its pure helpers ([`under_dir`], [`same_path`]) plus a live-process test.
|
||||
pub struct Scanner;
|
||||
|
||||
impl Scanner {
|
||||
pub fn system() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Seconds on the Windows process-start timeline — the `FILETIME` epoch, which is what
|
||||
/// `GetProcessTimes` reports a creation time in. `None` if the clock could not be read.
|
||||
///
|
||||
/// Derived from `SystemTime` rather than a Win32 call because both are the same UTC epoch offset
|
||||
/// by a constant, and the only use is comparing against a creation time read moments later; a
|
||||
/// clock step between the two would need to exceed [`START_SLACK_SECS`] to matter.
|
||||
pub fn now_stamp(&self) -> Option<f64> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?;
|
||||
/// Seconds between the `FILETIME` epoch (1601-01-01) and the Unix epoch.
|
||||
const FILETIME_EPOCH_OFFSET_SECS: f64 = 11_644_473_600.0;
|
||||
Some(now.as_secs_f64() + FILETIME_EPOCH_OFFSET_SECS)
|
||||
}
|
||||
|
||||
/// Every process matching any of `spec`'s signals, restricted to those that started at or after
|
||||
/// `min_start` (seconds on the [`Self::now_stamp`] timeline; `None` disables the filter).
|
||||
pub fn find(&self, spec: &DetectSpec, min_start: Option<f64>) -> Vec<ProcRef> {
|
||||
// Only the path-based signals exist on Windows: no reaper argv, no readable environment. A spec
|
||||
// carrying neither must match *nothing* — falling through would scan on an empty predicate.
|
||||
if spec.exe.is_none() && spec.install_dir.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let exe = spec
|
||||
.exe
|
||||
.as_deref()
|
||||
.map(|e| e.canonicalize().unwrap_or_else(|_| e.to_path_buf()));
|
||||
let dir = spec
|
||||
.install_dir
|
||||
.as_deref()
|
||||
.map(|d| d.canonicalize().unwrap_or_else(|_| d.to_path_buf()));
|
||||
|
||||
let mut out = Vec::new();
|
||||
for pid in snapshot_pids() {
|
||||
// pid 0 (System Idle) and 4 (System) are neither openable nor ever a game.
|
||||
if pid <= 4 {
|
||||
continue;
|
||||
}
|
||||
let Some((start, image)) = process_start_and_image(pid) else {
|
||||
continue; // exited mid-scan, or a protected process we can't query — never a game
|
||||
};
|
||||
if let Some(min) = min_start {
|
||||
if start as f64 / FILETIME_TICKS_PER_SEC + START_SLACK_SECS < min {
|
||||
continue; // predates this launch — never ours (rule 1)
|
||||
}
|
||||
}
|
||||
let hit = exe.as_deref().is_some_and(|w| same_path(&image, w))
|
||||
|| dir.as_deref().is_some_and(|d| under_dir(&image, d));
|
||||
if hit {
|
||||
out.push(ProcRef { pid, start });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Which of `procs` are still the same live processes — pid present **and** creation time
|
||||
/// unchanged, so a recycled pid is never reported alive (rule 2). Windows reuses pids briskly, so
|
||||
/// this check is what makes signalling a remembered pid safe at all.
|
||||
pub fn alive(&self, procs: &[ProcRef]) -> Vec<ProcRef> {
|
||||
procs
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|p| process_start_and_image(p.pid).is_some_and(|(start, _)| start == p.start))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// An out-of-band opinion on whether the game is still running, used only to **veto** declaring it
|
||||
/// gone (see [`super::running_hint`]).
|
||||
///
|
||||
/// For a Steam title: Steam records a per-app `Running` flag in the logged-in user's hive. That flag is
|
||||
/// not trustworthy on its own — Steam sets it around updates and DLC installs too, and leaves it stale
|
||||
/// if it crashes — but as a veto it is exactly right. If the matcher can't see the game (its executable
|
||||
/// lives outside the install dir the manifest named, or a nested launcher owns it) while Steam still
|
||||
/// says the app is running, ending the session would be a false positive the player feels immediately.
|
||||
/// Erring towards "still running" only ever leaves a stream up.
|
||||
///
|
||||
/// Reads every **loaded** user hive under `HKEY_USERS` rather than resolving the interactive user's SID
|
||||
/// through `WTSQueryUserToken`: only logged-in users' hives are loaded, which is the same set, and it
|
||||
/// avoids a token dance for a best-effort hint.
|
||||
pub fn steam_running_hint(appid: u32) -> Option<bool> {
|
||||
use winreg::enums::{HKEY_USERS, KEY_READ};
|
||||
use winreg::RegKey;
|
||||
|
||||
let users = RegKey::predef(HKEY_USERS);
|
||||
let mut saw_key = false;
|
||||
for sid in users.enum_keys().flatten() {
|
||||
// The `…_Classes` companion hives hold no Steam state.
|
||||
if sid.ends_with("_Classes") {
|
||||
continue;
|
||||
}
|
||||
let path = format!("{sid}\\Software\\Valve\\Steam\\Apps\\{appid}");
|
||||
let Ok(app) = users.open_subkey_with_flags(&path, KEY_READ) else {
|
||||
continue;
|
||||
};
|
||||
saw_key = true;
|
||||
if app.get_value::<u32, _>("Running").unwrap_or(0) != 0 {
|
||||
return Some(true);
|
||||
}
|
||||
}
|
||||
// A key that exists and says 0 is a real "not running"; no key at all means Steam has never run
|
||||
// this app on this box, which is no opinion rather than a negative one.
|
||||
saw_key.then_some(false)
|
||||
}
|
||||
|
||||
/// Every pid in a Toolhelp snapshot. Mirrors the walk in [`crate::detect`]'s Windows facts, which
|
||||
/// wants basenames rather than pids.
|
||||
fn snapshot_pids() -> Vec<u32> {
|
||||
let mut out = Vec::new();
|
||||
// SAFETY: the canonical Toolhelp walk. `entry` is zeroed with `dwSize` set before the first read
|
||||
// (the 260-wide `szExeFile` array has no usable `Default`), and the snapshot handle is closed on
|
||||
// every exit path.
|
||||
unsafe {
|
||||
let Ok(snap) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else {
|
||||
return out;
|
||||
};
|
||||
let mut entry = PROCESSENTRY32W {
|
||||
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
|
||||
..std::mem::zeroed()
|
||||
};
|
||||
if Process32FirstW(snap, &mut entry).is_ok() {
|
||||
loop {
|
||||
out.push(entry.th32ProcessID);
|
||||
if Process32NextW(snap, &mut entry).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = CloseHandle(snap);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A process's creation time (`FILETIME` ticks) and full image path.
|
||||
///
|
||||
/// Opened with `PROCESS_QUERY_LIMITED_INFORMATION` — the least privilege that answers both questions,
|
||||
/// and the one that works against elevated and protected-ish processes without asking for the right to
|
||||
/// read their memory. `None` for anything that can't be opened or has already exited, which is the
|
||||
/// routine case during a scan and never a reason to log.
|
||||
fn process_start_and_image(pid: u32) -> Option<(u64, PathBuf)> {
|
||||
// SAFETY: `OpenProcess` yields an owned handle only on `Ok`; it is closed exactly once below on
|
||||
// every path. `GetProcessTimes` writes four `FILETIME`s we fully own; `QueryFullProcessImageNameW`
|
||||
// writes into `buf` and updates `len` in place, bounded by the `len` we pass in (buf.len()).
|
||||
unsafe {
|
||||
let handle: HANDLE = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?;
|
||||
let mut created = Default::default();
|
||||
let (mut exit, mut kernel, mut user) =
|
||||
(Default::default(), Default::default(), Default::default());
|
||||
let times =
|
||||
GetProcessTimes(handle, &mut created, &mut exit, &mut kernel, &mut user).is_ok();
|
||||
|
||||
let mut buf = [0u16; IMAGE_PATH_MAX];
|
||||
let mut len = buf.len() as u32;
|
||||
// `PROCESS_NAME_FORMAT(0)` is `PROCESS_NAME_WIN32` — a drive-letter path, which is what the
|
||||
// library's store-derived paths look like (the alternative, `PROCESS_NAME_NATIVE`, yields
|
||||
// `\Device\HarddiskVolume…` and would never compare equal to one).
|
||||
let named = QueryFullProcessImageNameW(
|
||||
handle,
|
||||
PROCESS_NAME_FORMAT(0),
|
||||
windows::core::PWSTR(buf.as_mut_ptr()),
|
||||
&mut len,
|
||||
)
|
||||
.is_ok();
|
||||
let _ = CloseHandle(handle);
|
||||
|
||||
if !times || !named {
|
||||
return None;
|
||||
}
|
||||
let start = ((created.dwHighDateTime as u64) << 32) | created.dwLowDateTime as u64;
|
||||
let image = PathBuf::from(String::from_utf16_lossy(&buf[..len as usize]));
|
||||
Some((start, image))
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `image` the same file as `want`? Windows paths are case-insensitive, and the scanner compares a
|
||||
/// canonicalized store-derived path against a live image path, so the comparison must be too.
|
||||
fn same_path(image: &Path, want: &Path) -> bool {
|
||||
eq_ignore_case(image, want)
|
||||
}
|
||||
|
||||
/// Does `image` live under `dir`? Case-insensitive, and requires a separator after the directory so an
|
||||
/// install dir of `…\Games\X` is not satisfied by `…\Games\XY\game.exe`.
|
||||
fn under_dir(image: &Path, dir: &Path) -> bool {
|
||||
let (i, d) = (wide_lower(image), wide_lower(dir));
|
||||
let Some(rest) = i.strip_prefix(d.as_str()) else {
|
||||
return false;
|
||||
};
|
||||
rest.starts_with('\\') || rest.starts_with('/')
|
||||
}
|
||||
|
||||
fn eq_ignore_case(a: &Path, b: &Path) -> bool {
|
||||
wide_lower(a) == wide_lower(b)
|
||||
}
|
||||
|
||||
/// Lowercase a path for comparison, normalizing the `\\?\` prefix `canonicalize` prepends (a
|
||||
/// store-derived path canonicalizes to the verbatim form while a live image path does not, and the two
|
||||
/// must still compare equal).
|
||||
fn wide_lower(p: &Path) -> String {
|
||||
let s = p.to_string_lossy();
|
||||
let s = s.strip_prefix(r"\\?\").unwrap_or(&s);
|
||||
s.to_lowercase()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn install_dir_match_is_case_insensitive_and_separator_aware() {
|
||||
let dir = Path::new(r"C:\Games\Hades");
|
||||
assert!(under_dir(Path::new(r"c:\games\hades\Hades.exe"), dir));
|
||||
assert!(under_dir(Path::new(r"C:\Games\Hades\bin\crash.exe"), dir));
|
||||
// A sibling whose name merely starts with the same text is not under it.
|
||||
assert!(!under_dir(Path::new(r"C:\Games\HadesII\game.exe"), dir));
|
||||
// The directory itself is not "under" itself (no process image is a bare directory anyway).
|
||||
assert!(!under_dir(dir, dir));
|
||||
assert!(!under_dir(Path::new(r"D:\Games\Hades\Hades.exe"), dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exe_match_is_case_insensitive_and_ignores_the_verbatim_prefix() {
|
||||
// `canonicalize` yields the `\\?\` verbatim form; a live image path never has it.
|
||||
assert!(same_path(
|
||||
Path::new(r"C:\Games\Hades\Hades.exe"),
|
||||
Path::new(r"\\?\c:\games\hades\hades.exe")
|
||||
));
|
||||
assert!(!same_path(
|
||||
Path::new(r"C:\Games\Hades\Hades.exe"),
|
||||
Path::new(r"C:\Games\Hades\Other.exe")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spec_with_no_path_signal_matches_nothing() {
|
||||
// No reaper and no environment reading on Windows: an appid-only or env-only spec has nothing
|
||||
// to match here, and must not fall through to matching everything.
|
||||
let s = Scanner::system();
|
||||
assert!(s.find(&DetectSpec::steam(570), None).is_empty());
|
||||
assert!(s
|
||||
.find(
|
||||
&DetectSpec::default().with_env("HEROIC_APP_NAME", Some("Quail".into())),
|
||||
None
|
||||
)
|
||||
.is_empty());
|
||||
assert!(s.find(&DetectSpec::default(), None).is_empty());
|
||||
}
|
||||
|
||||
/// The live check: scan the real process table for this test process itself, which is the only way
|
||||
/// to notice a wrong `PROCESSENTRY32W` size, a failing `OpenProcess` access mask, or creation
|
||||
/// times read from the wrong `FILETIME` half.
|
||||
#[test]
|
||||
fn finds_this_process_by_its_own_image_path() {
|
||||
let me = std::env::current_exe().expect("current exe");
|
||||
let s = Scanner::system();
|
||||
let pid = std::process::id();
|
||||
let found = s.find(&DetectSpec::exe(&me), None);
|
||||
assert!(
|
||||
found.iter().any(|p| p.pid == pid),
|
||||
"scanning the real process table did not find this test process ({pid}) as {}",
|
||||
me.display()
|
||||
);
|
||||
// Its own directory finds it too.
|
||||
let dir = me.parent().expect("exe has a parent");
|
||||
assert!(s
|
||||
.find(&DetectSpec::dir(dir), None)
|
||||
.iter()
|
||||
.any(|p| p.pid == pid));
|
||||
// The same process re-verifies as alive by (pid, creation time)…
|
||||
let mine: Vec<ProcRef> = found.into_iter().filter(|p| p.pid == pid).collect();
|
||||
assert_eq!(s.alive(&mine), mine);
|
||||
// …and a wrong creation time for the same pid does not.
|
||||
let recycled = vec![ProcRef {
|
||||
pid,
|
||||
start: mine[0].start ^ 0xFFFF,
|
||||
}];
|
||||
assert!(s.alive(&recycled).is_empty());
|
||||
// A launch reference in the future excludes it — rule 1, against a real creation time.
|
||||
let future = s.now_stamp().unwrap() + 3_600.0;
|
||||
assert!(s.find(&DetectSpec::exe(&me), Some(future)).is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user