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:
@@ -156,7 +156,7 @@ pub struct LeaseShared {
|
|||||||
spec: DetectSpec,
|
spec: DetectSpec,
|
||||||
/// Seconds-since-boot at launch: the floor for adopting a process (`None` = the uptime clock was
|
/// Seconds-since-boot at launch: the floor for adopting a process (`None` = the uptime clock was
|
||||||
/// unreadable, so no start-time filtering is possible and only signals are used).
|
/// unreadable, so no start-time filtering is possible and only signals are used).
|
||||||
launch_uptime: Option<f64>,
|
launch_stamp: Option<f64>,
|
||||||
/// The child the host spawned for this launch, when it spawned one ([`LeaseKind::Child`]).
|
/// The child the host spawned for this launch, when it spawned one ([`LeaseKind::Child`]).
|
||||||
/// Cleared once that child is reaped.
|
/// Cleared once that child is reaped.
|
||||||
child: Mutex<Option<OwnedChild>>,
|
child: Mutex<Option<OwnedChild>>,
|
||||||
@@ -254,15 +254,15 @@ pub struct LeaseRequest {
|
|||||||
/// Seconds-since-boot from **before** the launch ([`launch_clock`]): the floor for adopting a
|
/// Seconds-since-boot from **before** the launch ([`launch_clock`]): the floor for adopting a
|
||||||
/// process, which is what keeps a copy of the game the player already had open from being
|
/// process, which is what keeps a copy of the game the player already had open from being
|
||||||
/// mistaken for this session's. `None` disables the filter (no readable uptime clock).
|
/// mistaken for this session's. `None` disables the filter (no readable uptime clock).
|
||||||
pub launch_uptime: Option<f64>,
|
pub launch_stamp: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The reference instant for adopting this launch's processes, in seconds since boot. Call it
|
/// The reference instant for adopting this launch's processes, in seconds since boot. Call it
|
||||||
/// **before** anything spawns; see [`LeaseRequest::launch_uptime`].
|
/// **before** anything spawns; see [`LeaseRequest::launch_stamp`].
|
||||||
pub fn launch_clock() -> Option<f64> {
|
pub fn launch_clock() -> Option<f64> {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
crate::procscan::Scanner::system().uptime()
|
crate::procscan::launch_stamp()
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
{
|
{
|
||||||
@@ -284,7 +284,7 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
|
|||||||
spec,
|
spec,
|
||||||
nested,
|
nested,
|
||||||
child,
|
child,
|
||||||
launch_uptime,
|
launch_stamp,
|
||||||
} = req;
|
} = req;
|
||||||
|
|
||||||
let kind = if nested {
|
let kind = if nested {
|
||||||
@@ -310,7 +310,7 @@ pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
|
|||||||
state: AtomicU8::new(GameState::Launching as u8),
|
state: AtomicU8::new(GameState::Launching as u8),
|
||||||
cancel: Arc::new(AtomicBool::new(false)),
|
cancel: Arc::new(AtomicBool::new(false)),
|
||||||
spec,
|
spec,
|
||||||
launch_uptime,
|
launch_stamp,
|
||||||
child: Mutex::new(owned),
|
child: Mutex::new(owned),
|
||||||
terminating: AtomicBool::new(false),
|
terminating: AtomicBool::new(false),
|
||||||
created_ms: now_ms(),
|
created_ms: now_ms(),
|
||||||
@@ -366,14 +366,14 @@ fn spawn_watcher(
|
|||||||
if matches!(shared.kind, LeaseKind::Nested) && shared.spec.is_empty() {
|
if matches!(shared.kind, LeaseKind::Nested) && shared.spec.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
// Platforms with no matcher at all (macOS has no launch path either) keep a lease for the status
|
||||||
|
// surface, but nothing polls it.
|
||||||
|
#[cfg(not(any(target_os = "linux", windows)))]
|
||||||
{
|
{
|
||||||
// Windows lands in phase 2 (Job Objects + Toolhelp matching); until then a lease exists for
|
|
||||||
// the status surface but nothing polls.
|
|
||||||
let _ = (child, on_exit);
|
let _ = (child, on_exit);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
{
|
{
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("pf1-gamelease".into())
|
.name("pf1-gamelease".into())
|
||||||
@@ -383,13 +383,22 @@ fn spawn_watcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The watch loop: wait for the game to appear, then for it to go away.
|
/// The watch loop: wait for the game to appear, then for it to go away.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
fn watch(shared: Arc<LeaseShared>, mut child: Option<std::process::Child>, on_exit: OnExit) {
|
fn watch(shared: Arc<LeaseShared>, mut child: Option<std::process::Child>, on_exit: OnExit) {
|
||||||
let scanner = crate::procscan::Scanner::system();
|
let scanner = crate::procscan::Scanner::system();
|
||||||
let cancelled = || shared.cancel.load(Ordering::Relaxed);
|
let cancelled = || shared.cancel.load(Ordering::Relaxed);
|
||||||
let spawned_at = Instant::now();
|
let spawned_at = Instant::now();
|
||||||
let mut kind = shared.kind.clone();
|
let mut kind = shared.kind.clone();
|
||||||
|
|
||||||
|
// The game's processes as last seen. Phase 2 re-verifies these rather than re-scanning the whole
|
||||||
|
// process table each second: `alive` costs one query per known process, while `find` costs one per
|
||||||
|
// process on the box (and on Windows that means an `OpenProcess` each). A full re-scan still
|
||||||
|
// happens the moment they all vanish, which is what catches a game that re-execs into a new pid.
|
||||||
|
//
|
||||||
|
// Deliberately uninitialized: the only way out of phase 1 and into phase 2 is the branch that
|
||||||
|
// assigns it, so an initial value would be dead.
|
||||||
|
let mut known: Vec<crate::procscan::ProcRef>;
|
||||||
|
|
||||||
// ---- Phase 1: wait for the game to show up. ----
|
// ---- Phase 1: wait for the game to show up. ----
|
||||||
let start_deadline = spawned_at + START_GRACE;
|
let start_deadline = spawned_at + START_GRACE;
|
||||||
loop {
|
loop {
|
||||||
@@ -458,8 +467,9 @@ fn watch(shared: Arc<LeaseShared>, mut child: Option<std::process::Child>, on_ex
|
|||||||
// The child being alive counts as the game running for a `Child` lease, so a title with no
|
// The child being alive counts as the game running for a `Child` lease, so a title with no
|
||||||
// detect signals is still fully tracked.
|
// detect signals is still fully tracked.
|
||||||
let child_alive = matches!(kind, LeaseKind::Child) && child.is_some();
|
let child_alive = matches!(kind, LeaseKind::Child) && child.is_some();
|
||||||
let live = scanner.find(&shared.spec, shared.launch_uptime);
|
let live = scanner.find(&shared.spec, shared.launch_stamp);
|
||||||
if !live.is_empty() || child_alive {
|
if !live.is_empty() || child_alive {
|
||||||
|
known = live.clone();
|
||||||
shared.was_running.store(true, Ordering::Relaxed);
|
shared.was_running.store(true, Ordering::Relaxed);
|
||||||
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
|
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
|
||||||
shared.set_state(GameState::Running);
|
shared.set_state(GameState::Running);
|
||||||
@@ -487,6 +497,7 @@ fn watch(shared: Arc<LeaseShared>, mut child: Option<std::process::Child>, on_ex
|
|||||||
|
|
||||||
// ---- Phase 2: wait for it to go away, confirmed across a window. ----
|
// ---- Phase 2: wait for it to go away, confirmed across a window. ----
|
||||||
let mut gone_since: Option<Instant> = None;
|
let mut gone_since: Option<Instant> = None;
|
||||||
|
let mut vetoed = false;
|
||||||
loop {
|
loop {
|
||||||
if cancelled() {
|
if cancelled() {
|
||||||
return;
|
return;
|
||||||
@@ -502,22 +513,47 @@ fn watch(shared: Arc<LeaseShared>, mut child: Option<std::process::Child>, on_ex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let child_alive = matches!(kind, LeaseKind::Child) && child.is_some();
|
let child_alive = matches!(kind, LeaseKind::Child) && child.is_some();
|
||||||
// Re-scan rather than only re-checking known pids: a game that re-execs (a launcher stub
|
// Cheap first: are the processes we already know about still there? Only when none of them is
|
||||||
// becoming the real binary, an engine relaunching itself) keeps the same identity.
|
// do we pay for a full scan — which is also what notices a game that re-exec'd into a new pid
|
||||||
let live = scanner.find(&shared.spec, shared.launch_uptime);
|
// (a launcher stub becoming the real binary, an engine relaunching itself).
|
||||||
|
let live = {
|
||||||
|
let still = scanner.alive(&known);
|
||||||
|
if still.is_empty() {
|
||||||
|
scanner.find(&shared.spec, shared.launch_stamp)
|
||||||
|
} else {
|
||||||
|
still
|
||||||
|
}
|
||||||
|
};
|
||||||
if !live.is_empty() || child_alive {
|
if !live.is_empty() || child_alive {
|
||||||
|
known = live;
|
||||||
gone_since = None;
|
gone_since = None;
|
||||||
|
vetoed = false;
|
||||||
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
|
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
|
||||||
} else if gone_since.get_or_insert_with(Instant::now).elapsed() >= EXIT_CONFIRM {
|
} else if gone_since.get_or_insert_with(Instant::now).elapsed() >= EXIT_CONFIRM {
|
||||||
finish(&shared, &on_exit, "the game exited");
|
// Last check before ending a session: does anything outside the process scan still think
|
||||||
return;
|
// the game is up? Only a veto, never a reason to call it running — see
|
||||||
|
// `procscan::running_hint`. The failure mode of honoring it is a stream that stays up.
|
||||||
|
if crate::procscan::running_hint(&shared.spec) == Some(true) {
|
||||||
|
if !vetoed {
|
||||||
|
vetoed = true;
|
||||||
|
tracing::info!(
|
||||||
|
title = %shared.game.title,
|
||||||
|
"no game processes found, but its launcher still reports it running — not \
|
||||||
|
ending the session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
gone_since = None;
|
||||||
|
} else {
|
||||||
|
finish(&shared, &on_exit, "the game exited");
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
std::thread::sleep(POLL);
|
std::thread::sleep(POLL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record the exit and, unless the host itself ended the game, run the session-ending action.
|
/// Record the exit and, unless the host itself ended the game, run the session-ending action.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
fn finish(shared: &Arc<LeaseShared>, on_exit: &OnExit, why: &str) {
|
fn finish(shared: &Arc<LeaseShared>, on_exit: &OnExit, why: &str) {
|
||||||
shared.set_state(GameState::Exited);
|
shared.set_state(GameState::Exited);
|
||||||
let terminated = shared.is_terminating();
|
let terminated = shared.is_terminating();
|
||||||
@@ -621,6 +657,8 @@ fn terminate_blocking(shared: &LeaseShared) {
|
|||||||
LeaseKind::Child | LeaseKind::Matched => {
|
LeaseKind::Child | LeaseKind::Matched => {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
unix_term_ladder(shared);
|
unix_term_ladder(shared);
|
||||||
|
#[cfg(windows)]
|
||||||
|
windows_term_ladder(shared);
|
||||||
}
|
}
|
||||||
LeaseKind::Untracked => {}
|
LeaseKind::Untracked => {}
|
||||||
}
|
}
|
||||||
@@ -653,7 +691,7 @@ fn unix_term_ladder(shared: &LeaseShared) {
|
|||||||
// Re-scan and re-verify immediately before signalling, so a pid recycled since the last
|
// Re-scan and re-verify immediately before signalling, so a pid recycled since the last
|
||||||
// sweep is never hit.
|
// sweep is never hit.
|
||||||
scanner
|
scanner
|
||||||
.alive(&scanner.find(&shared.spec, shared.launch_uptime))
|
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
|
||||||
.into_iter()
|
.into_iter()
|
||||||
// SAFETY: as above, for a single pid just re-verified to be the process we adopted.
|
// SAFETY: as above, for a single pid just re-verified to be the process we adopted.
|
||||||
.filter(|p| unsafe { libc::kill(p.pid as i32, sig) == 0 })
|
.filter(|p| unsafe { libc::kill(p.pid as i32, sig) == 0 })
|
||||||
@@ -673,7 +711,7 @@ fn unix_term_ladder(shared: &LeaseShared) {
|
|||||||
while Instant::now() < deadline {
|
while Instant::now() < deadline {
|
||||||
std::thread::sleep(POLL);
|
std::thread::sleep(POLL);
|
||||||
let still = scanner
|
let still = scanner
|
||||||
.alive(&scanner.find(&shared.spec, shared.launch_uptime))
|
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
|
||||||
.len();
|
.len();
|
||||||
// Signal 0 only probes for existence — the child (or its group) is gone once it fails.
|
// Signal 0 only probes for existence — the child (or its group) is gone once it fails.
|
||||||
let child_gone = !signal_child(0);
|
let child_gone = !signal_child(0);
|
||||||
@@ -691,6 +729,52 @@ fn unix_term_ladder(shared: &LeaseShared) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Windows: ask the game's windows to close, wait, then terminate what's left.
|
||||||
|
///
|
||||||
|
/// Same shape as the Unix ladder, different primitives — and one structural difference: the host never
|
||||||
|
/// holds a child here. Every Windows launch goes through a launcher or the shell
|
||||||
|
/// (`steam://`, `com.epicgames.launcher://`, `shell:AppsFolder\…`), so the game is always recognized
|
||||||
|
/// rather than owned, and the pid set comes entirely from the matcher.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn windows_term_ladder(shared: &LeaseShared) {
|
||||||
|
let scanner = crate::procscan::Scanner::system();
|
||||||
|
let live = || scanner.alive(&scanner.find(&shared.spec, shared.launch_stamp));
|
||||||
|
|
||||||
|
let pids: Vec<u32> = live().into_iter().map(|p| p.pid).collect();
|
||||||
|
if pids.is_empty() {
|
||||||
|
tracing::info!(title = %shared.game.title, "the game is already gone — nothing to end");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Polite first: a `WM_CLOSE` is what clicking the window's X does, so the game runs its own
|
||||||
|
// shutdown and can save.
|
||||||
|
let asked = crate::game_term::request_close(&pids);
|
||||||
|
tracing::debug!(
|
||||||
|
title = %shared.game.title,
|
||||||
|
windows_asked = asked,
|
||||||
|
procs = pids.len(),
|
||||||
|
grace_s = TERM_GRACE.as_secs(),
|
||||||
|
"asked the game to close"
|
||||||
|
);
|
||||||
|
let deadline = Instant::now() + TERM_GRACE;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
std::thread::sleep(POLL);
|
||||||
|
if live().is_empty() {
|
||||||
|
tracing::info!(title = %shared.game.title, "the game closed when asked");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Re-verify (pid, creation time) one last time, then insist. Windows recycles pids briskly, so the
|
||||||
|
// list handed to `kill` must be freshly checked rather than the one gathered above.
|
||||||
|
let remaining: Vec<u32> = live().into_iter().map(|p| p.pid).collect();
|
||||||
|
let killed = crate::game_term::kill(&remaining);
|
||||||
|
tracing::warn!(
|
||||||
|
title = %shared.game.title,
|
||||||
|
killed,
|
||||||
|
grace_s = TERM_GRACE.as_secs(),
|
||||||
|
"the game did not close when asked — killed it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
// The grace registry: leases whose session is gone but whose game is on probation
|
// The grace registry: leases whose session is gone but whose game is on probation
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
@@ -886,7 +970,7 @@ mod tests {
|
|||||||
nested,
|
nested,
|
||||||
child: None,
|
child: None,
|
||||||
// No start-time floor: these leases are never matched against real processes.
|
// No start-time floor: these leases are never matched against real processes.
|
||||||
launch_uptime: None,
|
launch_stamp: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,7 +1107,7 @@ mod tests {
|
|||||||
let td = tempfile::tempdir().expect("tempdir");
|
let td = tempfile::tempdir().expect("tempdir");
|
||||||
let script = td.path().join("game.sh");
|
let script = td.path().join("game.sh");
|
||||||
std::fs::write(&script, "#!/bin/sh\nexec sleep 30\n").unwrap();
|
std::fs::write(&script, "#!/bin/sh\nexec sleep 30\n").unwrap();
|
||||||
let launch_uptime = launch_clock();
|
let launch_stamp = launch_clock();
|
||||||
let child = std::process::Command::new("/bin/sh")
|
let child = std::process::Command::new("/bin/sh")
|
||||||
.arg(&script)
|
.arg(&script)
|
||||||
.process_group(0)
|
.process_group(0)
|
||||||
@@ -1044,7 +1128,7 @@ mod tests {
|
|||||||
spec: DetectSpec::dir(td.path()),
|
spec: DetectSpec::dir(td.path()),
|
||||||
nested: false,
|
nested: false,
|
||||||
child: Some((child, true)),
|
child: Some((child, true)),
|
||||||
launch_uptime,
|
launch_stamp,
|
||||||
},
|
},
|
||||||
Box::new(|| {
|
Box::new(|| {
|
||||||
EXITS.fetch_add(1, Ordering::SeqCst);
|
EXITS.fetch_add(1, Ordering::SeqCst);
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ mod events;
|
|||||||
// The lifetime of a launched game: whether it is running, when it exits (which can end the session),
|
// The lifetime of a launched game: whether it is running, when it exits (which can end the session),
|
||||||
// and how to end it (which a session ending can ask for) — design/session-game-lifetime.md.
|
// and how to end it (which a session ending can ask for) — design/session-game-lifetime.md.
|
||||||
mod gamelease;
|
mod gamelease;
|
||||||
|
// The Win32 half of ending a game: WM_CLOSE onto the interactive desktop, then TerminateProcess.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "windows/game_term.rs"]
|
||||||
|
mod game_term;
|
||||||
mod gamestream;
|
mod gamestream;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "linux/gpuclocks.rs"]
|
#[path = "linux/gpuclocks.rs"]
|
||||||
@@ -70,8 +74,8 @@ mod native_pairing;
|
|||||||
mod pipeline;
|
mod pipeline;
|
||||||
mod plugins;
|
mod plugins;
|
||||||
// Finding a launched game's processes from its store's detect signals — the read side of the
|
// Finding a launched game's processes from its store's detect signals — the read side of the
|
||||||
// session⇄game lifetime binding (design/session-game-lifetime.md §4).
|
// session⇄game lifetime binding (design/session-game-lifetime.md §4). Per-OS matchers inside; on a
|
||||||
#[cfg(target_os = "linux")]
|
// platform with neither (macOS, which has no launch path either) the module is an empty shell.
|
||||||
mod procscan;
|
mod procscan;
|
||||||
mod send_pacing;
|
mod send_pacing;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
|||||||
@@ -1115,7 +1115,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// therefore before a bare-spawn gamescope's nested child) exists, because a reading taken after
|
// therefore before a bare-spawn gamescope's nested child) exists, because a reading taken after
|
||||||
// the launch would reject the very process it is meant to find. Erring early is the safe
|
// the launch would reject the very process it is meant to find. Erring early is the safe
|
||||||
// direction: it can only ever include more of our own launch, never a copy from before it.
|
// direction: it can only ever include more of our own launch, never a copy from before it.
|
||||||
let launch_uptime = crate::gamelease::launch_clock();
|
let launch_stamp = crate::gamelease::launch_clock();
|
||||||
// Streamed-AU wire mode: the client's cap AND the host escape hatch (`PUNKTFUNK_STREAMED_AU=0`
|
// Streamed-AU wire mode: the client's cap AND the host escape hatch (`PUNKTFUNK_STREAMED_AU=0`
|
||||||
// reverts to whole-AU sends without touching the encoder's slicing knobs). The third gate —
|
// reverts to whole-AU sends without touching the encoder's slicing knobs). The third gate —
|
||||||
// whether the ENCODER actually chunks — is dynamic (`supports_chunked_poll`, per AU).
|
// whether the ENCODER actually chunks — is dynamic (`supports_chunked_poll`, per AU).
|
||||||
@@ -1318,7 +1318,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
spec: target.detect.clone(),
|
spec: target.detect.clone(),
|
||||||
nested,
|
nested,
|
||||||
child,
|
child,
|
||||||
launch_uptime,
|
launch_stamp,
|
||||||
},
|
},
|
||||||
on_exit,
|
on_exit,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,580 +1,92 @@
|
|||||||
//! Finding a launched game's processes on Linux, from the signals its store gave us
|
//! Finding a launched game's processes, from the signals its store gave us
|
||||||
//! ([`crate::library::DetectSpec`]).
|
//! ([`crate::library::DetectSpec`]).
|
||||||
//!
|
//!
|
||||||
//! Everything here reads `/proc` and nothing else — no ptrace, no injection, no handles kept. The
|
//! Read-only by construction: the host enumerates processes and reads metadata it already has
|
||||||
//! host only ever looks at processes owned by its **own uid**, and only ever *reads* metadata it
|
//! permission to see. No ptrace, no injection, no handles held open. On Linux it looks only at
|
||||||
//! already has permission to see.
|
//! processes owned by its **own uid**; on Windows it runs as SYSTEM and therefore *can* see
|
||||||
|
//! everything, which makes the two rules below the load-bearing part rather than an afterthought.
|
||||||
//!
|
//!
|
||||||
//! Two rules keep this honest, and both matter more than the matching itself:
|
//! ### The two rules
|
||||||
//!
|
//!
|
||||||
//! 1. **Never adopt a process that predates the launch.** A player may already have the game open
|
//! 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
|
//! 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
|
//! kill a process it never started. Candidates are filtered by start time against
|
||||||
//! ([`Scanner::find`]'s `min_start`).
|
//! [`launch_stamp`], taken before anything spawns.
|
||||||
//! 2. **Never trust a bare pid.** Pids are recycled, and a lease can outlive its game by a grace
|
//! 2. **Never trust a bare pid.** Pids are recycled — aggressively so on Windows — and a lease can
|
||||||
//! window, so every remembered process carries its start time and is re-verified against it
|
//! outlive its game by a grace window, so every remembered process carries its start time and is
|
||||||
//! ([`Scanner::alive`]) before it is ever counted as running — or signalled.
|
//! re-verified against it ([`Scanner::alive`]) before it is counted as running, or signalled.
|
||||||
|
//!
|
||||||
|
//! ### Per-OS implementations
|
||||||
|
//!
|
||||||
|
//! The two have nothing in common beyond that contract, so they are separate modules presenting the
|
||||||
|
//! same [`Scanner`] surface: `/proc` on Linux, a Toolhelp snapshot on Windows. Everything above the
|
||||||
|
//! scanner ([`crate::gamelease`]) is platform-neutral.
|
||||||
|
|
||||||
use crate::library::DetectSpec;
|
#[cfg(target_os = "linux")]
|
||||||
use std::path::{Path, PathBuf};
|
mod linux;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub use linux::Scanner;
|
||||||
|
|
||||||
/// A process the matcher adopted: its pid plus the start time that pins that pid to *this*
|
#[cfg(windows)]
|
||||||
/// process, so a recycled pid can never be mistaken for it.
|
mod windows;
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub use windows::Scanner;
|
||||||
|
|
||||||
|
/// A process the matcher adopted: its pid plus a start stamp that pins that pid to *this* process,
|
||||||
|
/// so a recycled pid can never be mistaken for it.
|
||||||
|
///
|
||||||
|
/// `start` is opaque and only ever compared for equality against a later read of the same pid — its
|
||||||
|
/// units differ per platform (clock ticks since boot on Linux, a creation `FILETIME` on Windows).
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub struct ProcRef {
|
pub struct ProcRef {
|
||||||
pub pid: u32,
|
pub pid: u32,
|
||||||
/// `/proc/<pid>/stat` field 22 — start time in clock ticks since boot. Unique per (pid, boot).
|
pub start: u64,
|
||||||
pub start_ticks: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Largest `cmdline`/`environ` blob we will read from a single process. Both are bounded by `ARG_MAX`
|
/// Tolerance on the "started after the launch" test, in seconds.
|
||||||
/// in practice; the cap exists so a pathological process can't make the host allocate without bound
|
///
|
||||||
/// during a once-a-second scan.
|
/// The launch stamp is taken just before the spawn, but start times are quantized (~10 ms on Linux)
|
||||||
const MAX_PROC_BLOB: u64 = 512 * 1024;
|
/// and a launcher can race ahead of the host's own bookkeeping, so 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.
|
||||||
|
pub const START_SLACK_SECS: f64 = 2.0;
|
||||||
|
|
||||||
/// Tolerance on the "started after the launch" test, in seconds. The launch timestamp is taken just
|
/// The reference instant for adopting a launch's processes, in **seconds on the platform's
|
||||||
/// before the spawn, but clock-tick granularity (`starttime` is quantized to ~10 ms) and a launcher
|
/// process-start timeline** — seconds since boot on Linux, seconds since the Windows epoch on
|
||||||
/// that raced ahead of our own bookkeeping mean an exact comparison would occasionally reject the
|
/// Windows. Only ever compared against a process's own start time on the same platform, never
|
||||||
/// real game. Two seconds is far below the time any launcher takes to bring a game up, so it cannot
|
/// interpreted as a wall clock or persisted.
|
||||||
/// let a *pre-existing* instance through.
|
///
|
||||||
const START_SLACK_SECS: f64 = 2.0;
|
/// Call it **before** anything spawns; see [`crate::gamelease::LeaseRequest::launch_stamp`]. `None`
|
||||||
|
/// when the platform has no matcher (macOS) or the clock could not be read, which disables the
|
||||||
/// A `/proc` reader. Parameterized on its root, uid filter, and clock rate so the matching logic can
|
/// start-time filter rather than rejecting everything.
|
||||||
/// be unit-tested against a fixture tree instead of the live system.
|
pub fn launch_stamp() -> Option<f64> {
|
||||||
pub struct Scanner {
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
root: PathBuf,
|
{
|
||||||
/// Only consider processes owned by this uid. `None` (tests only) considers all.
|
Scanner::system().now_stamp()
|
||||||
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 },
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
#[cfg(not(any(target_os = "linux", windows)))]
|
||||||
/// 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).
|
None
|
||||||
pub fn uptime(&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_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_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/<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
|
/// An out-of-band opinion on whether a spec's game is still running, independent of the process scan.
|
||||||
/// or the file is unreadable — both routine during a scan.
|
///
|
||||||
fn read_capped(path: &Path) -> Option<Vec<u8>> {
|
/// Consulted **only to veto** declaring a game gone — never to declare it running, and never as the
|
||||||
use std::io::Read;
|
/// primary signal. `Some(true)` = something else believes it is up, so hold off; `Some(false)` = that
|
||||||
let file = std::fs::File::open(path).ok()?;
|
/// something agrees it is gone; `None` = no opinion available, which is the common case.
|
||||||
let mut buf = Vec::new();
|
///
|
||||||
file.take(MAX_PROC_BLOB).read_to_end(&mut buf).ok()?;
|
/// Linux has none by design: Steam's launch reaper is both the sharpest signal and already a *process*,
|
||||||
Some(buf)
|
/// so it is covered by the scan itself. Windows has no reaper, which is exactly where a second opinion
|
||||||
}
|
/// earns its keep.
|
||||||
|
pub fn running_hint(spec: &crate::library::DetectSpec) -> Option<bool> {
|
||||||
#[cfg(test)]
|
#[cfg(windows)]
|
||||||
mod tests {
|
{
|
||||||
use super::*;
|
spec.steam_appid.and_then(windows::steam_running_hint)
|
||||||
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<PathBuf>,
|
|
||||||
cmdline: Vec<&'static str>,
|
|
||||||
environ: Vec<&'static str>,
|
|
||||||
}
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
impl FakeProc {
|
{
|
||||||
fn new(pid: u32, start_ticks: u64) -> Self {
|
let _ = spec;
|
||||||
Self {
|
None
|
||||||
pid,
|
|
||||||
start_ticks,
|
|
||||||
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_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<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_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());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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.uptime().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.uptime().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.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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
//! Asking a game to close on Windows, and killing it if it won't.
|
||||||
|
//!
|
||||||
|
//! The two halves of [`crate::gamelease`]'s termination ladder that need Win32: post `WM_CLOSE` to the
|
||||||
|
//! game's windows, then `TerminateProcess` whatever ignored it. Kept out of `procscan` (read-only by
|
||||||
|
//! construction) and out of `gamelease` (platform-neutral orchestration).
|
||||||
|
//!
|
||||||
|
//! ### Why the desktop dance
|
||||||
|
//!
|
||||||
|
//! The polite half is the reason this module is not three lines. `EnumWindows` enumerates the windows
|
||||||
|
//! of the **calling thread's desktop** — and the host runs as SYSTEM in session 0, whose desktop has
|
||||||
|
//! none of the interactive user's windows on it. Without first binding the thread to the input desktop
|
||||||
|
//! the enumeration comes back empty and the ladder silently degrades to killing every game outright,
|
||||||
|
//! which is exactly the unsaved-progress outcome the grace window exists to avoid.
|
||||||
|
//!
|
||||||
|
//! Same `OpenInputDesktop`/`SetThreadDesktop` pattern the input injector uses for `SendInput`
|
||||||
|
//! (`pf-inject`'s `sendinput.rs`), for the same reason: a UAC prompt, the lock screen or
|
||||||
|
//! Ctrl-Alt-Del swaps the input desktop out from under us.
|
||||||
|
|
||||||
|
use windows::Win32::Foundation::{CloseHandle, HWND, LPARAM, WPARAM};
|
||||||
|
use windows::Win32::System::StationsAndDesktops::{
|
||||||
|
CloseDesktop, OpenInputDesktop, SetThreadDesktop, DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS,
|
||||||
|
HDESK,
|
||||||
|
};
|
||||||
|
use windows::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
|
EnumWindows, GetWindowThreadProcessId, IsWindowVisible, PostMessageW, WM_CLOSE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Exit code reported for a game the host killed. Arbitrary but non-zero, so anything reading it can
|
||||||
|
/// tell it apart from a clean quit.
|
||||||
|
const KILLED_EXIT_CODE: u32 = 1;
|
||||||
|
|
||||||
|
/// `GENERIC_ALL` for a desktop open. The `windows` crate models desktop rights as their own type, so
|
||||||
|
/// the generic right isn't reachable through it — the same local const the input backends define
|
||||||
|
/// (`pf-inject`'s `sendinput.rs` / `pointer_windows.rs`).
|
||||||
|
const DESKTOP_GENERIC_ALL: u32 = 0x1000_0000;
|
||||||
|
|
||||||
|
/// Binds the calling thread to the desktop currently receiving input, for as long as it is held.
|
||||||
|
///
|
||||||
|
/// The thread this runs on is dedicated and short-lived (`pf1-gameterm`), so the previous desktop is
|
||||||
|
/// not restored — only the handle is released.
|
||||||
|
struct InputDesktop(HDESK);
|
||||||
|
|
||||||
|
impl InputDesktop {
|
||||||
|
/// `None` when the input desktop can't be opened or bound (an unprivileged host, or a secure
|
||||||
|
/// desktop we aren't allowed onto). Callers degrade rather than fail: without it the polite pass
|
||||||
|
/// finds no windows, and the kill pass still works.
|
||||||
|
fn attach() -> Option<Self> {
|
||||||
|
// SAFETY: FFI calls taking by-value args only (constant desktop flags, a bool, an access
|
||||||
|
// mask). `OpenInputDesktop` yields an owned `HDESK` only on `Ok`; it is then either installed
|
||||||
|
// on this thread and owned by the returned guard (closed exactly once in `Drop`), or closed
|
||||||
|
// here on the failure path. `SetThreadDesktop` rebinds only the calling thread — which owns
|
||||||
|
// this guard — and succeeds only when that thread has no windows or hooks, true of the fresh
|
||||||
|
// termination thread.
|
||||||
|
unsafe {
|
||||||
|
let h = OpenInputDesktop(
|
||||||
|
DESKTOP_CONTROL_FLAGS(0),
|
||||||
|
false,
|
||||||
|
DESKTOP_ACCESS_FLAGS(DESKTOP_GENERIC_ALL),
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
if SetThreadDesktop(h).is_ok() {
|
||||||
|
Some(Self(h))
|
||||||
|
} else {
|
||||||
|
let _ = CloseDesktop(h);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for InputDesktop {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `self.0` is the handle this guard owns and has not closed; `CloseDesktop` runs once
|
||||||
|
// here with no later use.
|
||||||
|
unsafe {
|
||||||
|
let _ = CloseDesktop(self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the enumeration callback needs, passed through `LPARAM`.
|
||||||
|
///
|
||||||
|
/// Owns its pid list rather than borrowing: the value round-trips through a raw pointer, and a
|
||||||
|
/// borrowed lifetime there is a subtlety with no upside for one small allocation per termination.
|
||||||
|
struct CloseCtx {
|
||||||
|
pids: Vec<u32>,
|
||||||
|
posted: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask every visible top-level window belonging to `pids` to close, the way clicking its X would —
|
||||||
|
/// so a game runs its own shutdown path and gets the chance to save. Returns how many windows were
|
||||||
|
/// asked.
|
||||||
|
///
|
||||||
|
/// Zero is a perfectly ordinary answer: a game may be mid-load with no window yet, or the host may not
|
||||||
|
/// have reached the input desktop. The caller's job is to wait and then insist, not to treat this as
|
||||||
|
/// success.
|
||||||
|
pub fn request_close(pids: &[u32]) -> usize {
|
||||||
|
if pids.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Without the input desktop this enumerates session 0 and finds nothing (see the module docs), so
|
||||||
|
// failing to attach means skipping the polite pass rather than doing it uselessly. Held for the
|
||||||
|
// whole enumeration.
|
||||||
|
let Some(_desktop) = InputDesktop::attach() else {
|
||||||
|
tracing::debug!(
|
||||||
|
"could not bind to the input desktop — skipping the polite close and going straight to \
|
||||||
|
the kill pass"
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let mut ctx = CloseCtx {
|
||||||
|
pids: pids.to_vec(),
|
||||||
|
posted: 0,
|
||||||
|
};
|
||||||
|
// SAFETY: `EnumWindows` invokes `enum_close` synchronously for each top-level window on this
|
||||||
|
// thread's desktop and returns before this frame exits, so the `&mut ctx` pointer it carries in
|
||||||
|
// the `LPARAM` stays valid for the whole call and is not aliased (nothing else touches `ctx`
|
||||||
|
// while the enumeration runs).
|
||||||
|
unsafe {
|
||||||
|
let _ = EnumWindows(Some(enum_close), LPARAM(&mut ctx as *mut CloseCtx as isize));
|
||||||
|
}
|
||||||
|
ctx.posted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `EnumWindows` callback: post `WM_CLOSE` to each visible window owned by one of the target pids.
|
||||||
|
///
|
||||||
|
/// Visible top-level windows only — a game's message-only and tool windows would swallow the close, and
|
||||||
|
/// posting to them achieves nothing while making the count meaningless.
|
||||||
|
unsafe extern "system" fn enum_close(hwnd: HWND, lparam: LPARAM) -> windows::core::BOOL {
|
||||||
|
// SAFETY: `lparam` is the `&mut CloseCtx` `request_close` passed in, valid for the whole
|
||||||
|
// enumeration; the callback is invoked synchronously on the same thread, so this is the only live
|
||||||
|
// reference to it.
|
||||||
|
let ctx = unsafe { &mut *(lparam.0 as *mut CloseCtx) };
|
||||||
|
let mut pid = 0u32;
|
||||||
|
// SAFETY: `hwnd` is the window the enumeration handed us; `pid` is a live local we own.
|
||||||
|
unsafe {
|
||||||
|
GetWindowThreadProcessId(hwnd, Some(&mut pid));
|
||||||
|
if ctx.pids.contains(&pid) && IsWindowVisible(hwnd).as_bool() {
|
||||||
|
// Posted, not sent: a `SendMessage` would block this thread on a hung game's message pump.
|
||||||
|
if PostMessageW(Some(hwnd), WM_CLOSE, WPARAM(0), LPARAM(0)).is_ok() {
|
||||||
|
ctx.posted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true.into() // keep enumerating — a game can own several windows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill `pids` outright. Returns how many were terminated.
|
||||||
|
///
|
||||||
|
/// The caller must have re-verified each pid against its recorded start time immediately before
|
||||||
|
/// calling this ([`crate::procscan::Scanner::alive`]) — Windows recycles pids briskly, and this call is
|
||||||
|
/// unforgiving about being pointed at the wrong one.
|
||||||
|
pub fn kill(pids: &[u32]) -> usize {
|
||||||
|
let mut killed = 0;
|
||||||
|
for &pid in pids {
|
||||||
|
// SAFETY: `OpenProcess` yields an owned handle only on `Ok`, which is closed exactly once
|
||||||
|
// below; `TerminateProcess` takes it by value plus a plain exit code.
|
||||||
|
unsafe {
|
||||||
|
if let Ok(h) = OpenProcess(PROCESS_TERMINATE, false, pid) {
|
||||||
|
if TerminateProcess(h, KILLED_EXIT_CODE).is_ok() {
|
||||||
|
killed += 1;
|
||||||
|
}
|
||||||
|
let _ = CloseHandle(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
killed
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user