Files
punktfunk/crates/punktfunk-host/src/procscan/windows.rs
T
enricobuehler f0e71e928a
plugin-kit-publish / publish (push) Failing after 34s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 1m2s
apple / swift (push) Successful in 5m39s
ci / bench (push) Successful in 6m18s
deb / build-publish (push) Successful in 8m56s
decky / build-publish (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
android / android (push) Successful in 12m2s
deb / build-publish-client-arm64 (push) Successful in 7m35s
ci / rust-arm64 (push) Successful in 17m58s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m26s
windows-host / package (push) Successful in 19m41s
deb / build-publish-host (push) Successful in 18m45s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8m49s
arch / build-publish (push) Successful in 22m17s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9m4s
ci / rust (push) Failing after 23m6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12m7s
docker / deploy-docs (push) Successful in 28s
docker / build-push-arm64cross (push) Successful in 4m20s
apple / screenshots (push) Successful in 24m30s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m18s
fix(gamestream): the resolved launch command is what the backend nests
A Windows/macOS build compiled the resolved-launch parameter out entirely and
warned about it, because the two uses left were both Linux-gated. Both platforms
want the same value: off Linux `set_launch_command` is a backend no-op and a
library title resolves to no command at all (the interactive-session spawner
launches it by id), so the cfg split was carrying an older distinction that no
longer exists.

Also covers the Windows half of `process_name` with a test — the pure name match
plus a live scan for this test process, since `find`'s early return would
otherwise skip the signal entirely and the test would pass vacuously.

Gates: .133 check + clippy --all-targets clean with no warnings, 5/5 procscan
(incl. live); .21 299 tests + fmt.
2026-07-26 18:28:13 +02:00

380 lines
17 KiB
Rust

//! 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 image-based signals exist on Windows: no reaper argv, no readable environment. A
// spec carrying none must match *nothing* — falling through would scan on an empty predicate.
if spec.exe.is_none() && spec.install_dir.is_none() && spec.process_name.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))
// The operator-supplied fallback: the image's file name alone. Windows paths are
// case-insensitive anyway, so this matches the platform rather than relaxing anything.
|| spec
.process_name
.as_deref()
.is_some_and(|w| same_name(&image, w));
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('/')
}
/// Is `image`'s file name `want`? The operator-supplied [`DetectSpec::process_name`] fallback: a bare
/// name, compared against the image's last component only, so `Hades.exe` never matches a path that
/// merely contains it.
fn same_name(image: &Path, want: &str) -> bool {
image
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.eq_ignore_ascii_case(want.trim()))
}
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")
));
}
/// The operator-supplied fallback ([`DetectSpec::process_name`]): the image's own file name and
/// nothing else — never a path that merely contains the name.
#[test]
fn process_name_matches_the_image_name_only() {
assert!(same_name(
Path::new(r"C:\Games\Hades\Hades.exe"),
"hades.exe"
));
assert!(same_name(
Path::new(r"C:\Games\Hades\Hades.exe"),
" Hades.exe "
));
// A longer name that starts the same way is a different program.
assert!(!same_name(
Path::new(r"C:\Games\Hades\HadesLauncher.exe"),
"Hades.exe"
));
// A directory called the same thing doesn't qualify its contents.
assert!(!same_name(
Path::new(r"C:\Games\Hades.exe\other.exe"),
"Hades.exe"
));
// …and it reaches the live scan, which the `find` early-return would otherwise skip.
let me = std::env::current_exe().expect("current exe");
let name = me.file_name().and_then(|n| n.to_str()).expect("exe name");
let spec = DetectSpec {
process_name: Some(name.to_ascii_uppercase()),
..Default::default()
};
assert!(Scanner::system()
.find(&spec, None)
.iter()
.any(|p| p.pid == std::process::id()));
}
#[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());
}
}