wire/platform.rs
1//! Cross-platform process-management primitives.
2//!
3//! Wire historically called `pgrep` + `kill` directly, which gave us
4//! "unsupported platform" rot on Windows. v0.7.3 funnels every
5//! liveness check / command-line search / SIGTERM through this module
6//! so the Windows daemon + relay paths get the same teardown +
7//! respawn behavior the Linux + macOS paths have always had.
8//!
9//! ## Helpers
10//!
11//! - [`process_alive`] — "is pid <N> still around?"
12//! - [`find_processes_by_cmdline`] — `pgrep -f <pattern>` equivalent
13//! - [`kill_process`] — SIGTERM / SIGKILL equivalent (taskkill /T on
14//! Windows so the tree dies, not just the parent)
15//!
16//! Each helper returns conservative defaults on tool failure (empty
17//! Vec, `false`) so callers can chain them without aborting an upgrade
18//! mid-flight when one query hiccups.
19//!
20//! ### Bounded shell-out (#284.1)
21//!
22//! Every Windows shell-out below is wrapped in [`run_with_timeout`].
23//! PowerShell's `Get-CimInstance` can wedge — observed on a host with
24//! 254 stale `wire.exe` processes piled up by a broken SessionStart
25//! loop, but also any corrupted CIM repository — and any `wire status`
26//! / `wire up` / `wire doctor` call that lands on a wedged enumeration
27//! would block forever waiting on the child. The wrapper kills the
28//! child after `WIRE_PLATFORM_TIMEOUT_SECS` (default 5s) and the
29//! caller falls through to its existing tool-error fallback (empty
30//! Vec, `None`, etc.), so a probe that can't answer in 5s reads as
31//! "no answer" rather than "wedge the whole CLI".
32
33use std::process::{Command, Output, Stdio};
34use std::sync::mpsc;
35use std::thread;
36use std::time::Duration;
37
38/// Bounded timeout for Windows shell-outs in this module. Override via
39/// `WIRE_PLATFORM_TIMEOUT_SECS`. Default 5s — every probe in this
40/// module is a single PowerShell / tasklist call that completes in
41/// well under 500ms on a healthy host. POSIX builds never call this
42/// at runtime (the test module does, hence not `#[cfg(windows)]`-only),
43/// so silence the dead-code lint there.
44#[cfg_attr(not(windows), allow(dead_code))]
45fn platform_shell_timeout() -> Duration {
46 std::env::var("WIRE_PLATFORM_TIMEOUT_SECS")
47 .ok()
48 .and_then(|s| s.parse::<u64>().ok())
49 .map(Duration::from_secs)
50 .unwrap_or_else(|| Duration::from_secs(5))
51}
52
53/// Run `cmd` with a wall-clock timeout. Returns `Some(Output)` on
54/// completion, or `None` on timeout (or spawn failure / wait failure).
55/// On timeout the child is killed best-effort via a platform-native
56/// shell-out (`taskkill /F /T /PID` on Windows, `kill -9` on POSIX) so
57/// the wedged process tree exits with the wrapper.
58///
59/// `Stdio` defaults: stdin null, stdout/stderr piped. Callers may
60/// override `stdin` before calling but should leave the pipes alone —
61/// the reader thread relies on them being captured to drain output
62/// while we wait.
63///
64/// Implementation: spawn the child, hand `wait_with_output` to a
65/// background thread that sends the result through a channel, then
66/// `recv_timeout` on the main thread. On timeout we kill the PID via
67/// the OS-native tool (we can't call `Child::kill` here because the
68/// `Child` moved into the reader thread).
69pub fn run_with_timeout(mut cmd: Command, timeout: Duration) -> Option<Output> {
70 cmd.stdin(Stdio::null())
71 .stdout(Stdio::piped())
72 .stderr(Stdio::piped());
73 let child = cmd.spawn().ok()?;
74 let pid = child.id();
75 let (tx, rx) = mpsc::channel::<Output>();
76 thread::spawn(move || {
77 if let Ok(out) = child.wait_with_output() {
78 let _ = tx.send(out);
79 }
80 });
81 match rx.recv_timeout(timeout) {
82 Ok(out) => Some(out),
83 Err(_) => {
84 // Kill the wedged child by PID. Best-effort: a failure here
85 // just means the reader thread keeps waiting; the main
86 // thread already moved on with `None`.
87 kill_pid_best_effort(pid);
88 None
89 }
90 }
91}
92
93fn kill_pid_best_effort(pid: u32) {
94 #[cfg(unix)]
95 {
96 let _ = Command::new("kill")
97 .args(["-9", &pid.to_string()])
98 .stdin(Stdio::null())
99 .stdout(Stdio::null())
100 .stderr(Stdio::null())
101 .status();
102 }
103 #[cfg(windows)]
104 {
105 let _ = Command::new("taskkill.exe")
106 .args(["/F", "/T", "/PID", &pid.to_string()])
107 .stdin(Stdio::null())
108 .stdout(Stdio::null())
109 .stderr(Stdio::null())
110 .status();
111 }
112 #[cfg(not(any(unix, windows)))]
113 {
114 let _ = pid;
115 }
116}
117
118/// True iff pid is alive.
119///
120/// - Linux: `/proc/<pid>` exists (no fork, no shell-out).
121/// - macOS / BSD: `kill -0 <pid>` (signal 0 = check only).
122/// - Windows: `tasklist /FI "PID eq <pid>" /FO CSV /NH`. A miss prints
123/// `INFO: No tasks are running...` to stdout AND exits 0, so we
124/// detect by content rather than exit code.
125pub fn process_alive(pid: u32) -> bool {
126 #[cfg(target_os = "linux")]
127 {
128 std::path::Path::new(&format!("/proc/{pid}")).exists()
129 }
130 #[cfg(all(unix, not(target_os = "linux")))]
131 {
132 Command::new("kill")
133 .args(["-0", &pid.to_string()])
134 .stdin(std::process::Stdio::null())
135 .stdout(std::process::Stdio::null())
136 .stderr(std::process::Stdio::null())
137 .status()
138 .map(|s| s.success())
139 .unwrap_or(false)
140 }
141 #[cfg(windows)]
142 {
143 // Bounded: a wedged `tasklist` would hang every `wire status` /
144 // `wire doctor` it touches. 5s default — `tasklist /FI "PID eq …"`
145 // completes in well under 100ms on a healthy host.
146 let mut cmd = Command::new("tasklist.exe");
147 cmd.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]);
148 match run_with_timeout(cmd, platform_shell_timeout()) {
149 Some(o) if o.status.success() => {
150 let s = String::from_utf8_lossy(&o.stdout);
151 let trimmed = s.trim();
152 !trimmed.is_empty() && !trimmed.starts_with("INFO:")
153 }
154 // Timeout / failure → conservative `false` (treat as dead).
155 // Same fallback the old `Err(_) | Ok(non-success)` arm
156 // produced; `wire status` already handles "daemon missing"
157 // cleanly, and surfacing "timed out probing" is part of
158 // #284.1's bounded-but-loud story.
159 _ => false,
160 }
161 }
162}
163
164/// The role/subcommand of a `wire <role> ...` process pattern —
165/// `cmdline_role("wire daemon") == "daemon"`, `cmdline_role("wire
166/// relay-server") == "relay-server"`. A pattern without the `wire ` prefix
167/// passes through unchanged.
168///
169/// The Windows process scan matches this role (not the full `wire daemon`
170/// string) against the command line, because the image is `wire.exe` and the
171/// contiguous `wire daemon` never matches the real `wire.exe daemon` cmdline.
172/// Hoisted out of the `cfg(windows)` block + unit-tested so the `.exe`-match
173/// regression (which caused `wire upgrade` to accumulate daemons) is locked on
174/// EVERY platform's CI, not only on a Windows runner.
175#[cfg_attr(not(windows), allow(dead_code))]
176pub(crate) fn cmdline_role(pattern: &str) -> &str {
177 pattern.strip_prefix("wire ").unwrap_or(pattern)
178}
179
180/// `pgrep -f <pattern>` equivalent: every pid whose command line
181/// contains `pattern`. Empty Vec on tool error or zero matches.
182///
183/// - Unix: `pgrep -f <pattern>` (one fork, parses pid-per-line stdout).
184/// - Windows: PowerShell + CIM (`Get-CimInstance Win32_Process` with
185/// `CommandLine` filter). `wmic` was the old path but is deprecated
186/// in Windows 11 24H2; CIM is the supported replacement and works
187/// back to Windows 10. Pattern is single-quoted into the PowerShell
188/// `-like` operator so most metacharacters pass through verbatim;
189/// callers that need literal `'` or `[`/`]` should escape per
190/// PowerShell rules.
191pub fn find_processes_by_cmdline(pattern: &str) -> Vec<u32> {
192 #[cfg(unix)]
193 {
194 Command::new("pgrep")
195 .args(["-f", pattern])
196 .output()
197 .ok()
198 .filter(|o| o.status.success())
199 .map(|o| {
200 String::from_utf8_lossy(&o.stdout)
201 .split_whitespace()
202 .filter_map(|s| s.parse::<u32>().ok())
203 .collect()
204 })
205 .unwrap_or_default()
206 }
207 #[cfg(windows)]
208 {
209 // Single-quote the pattern in the PowerShell string. Inside
210 // single-quoted PS strings, the only escape is `''` for a
211 // literal single quote; we replace pre-emptively.
212 // The Windows process image is `wire.exe`, so a Unix-style full
213 // pattern like "wire daemon" does NOT match the actual command line
214 // "wire.exe daemon" (the ".exe " breaks the contiguous match). Match
215 // the wire image by Name and the ROLE/subcommand (the pattern minus a
216 // leading "wire ") in the command line. Without this, find returned
217 // nothing for the real daemon on Windows, so `wire upgrade` killed no
218 // daemons and they ACCUMULATED (glossy-magnolia: 2->3->4->5 over three
219 // upgrade cycles — the exact multi-daemon cursor race doctor warns of).
220 //
221 // Two further guards (glossy-magnolia repro):
222 // - `$_.Name -like 'wire*'` — only wire processes count. Without it
223 // the query SELF-MATCHED: this PowerShell process's own command
224 // line contains the pattern literal, so it showed up as a phantom
225 // "orphan daemon" with a new pid every call (doctor FAILed on
226 // every healthy box).
227 // - `$_.ProcessId -ne $PID` — belt-and-suspenders self-exclusion.
228 let role = cmdline_role(pattern);
229 let escaped = role.replace('\'', "''");
230 let ps = format!(
231 "Get-CimInstance Win32_Process | \
232 Where-Object {{ $_.Name -like 'wire*' -and $_.ProcessId -ne $PID -and $_.CommandLine -like '*{escaped}*' }} | \
233 Select-Object -ExpandProperty ProcessId"
234 );
235 // Bounded: a wedged `Get-CimInstance` (corrupted CIM repo, or
236 // simply slow under heavy WMI contention on a host with
237 // hundreds of stale `wire.exe` processes — see #284.1 / #284.2)
238 // would hang every CLI invocation it's reached from. 5s default.
239 let mut cmd = Command::new("powershell.exe");
240 cmd.args(["-NoProfile", "-NonInteractive", "-Command", &ps]);
241 run_with_timeout(cmd, platform_shell_timeout())
242 .filter(|o| o.status.success())
243 .map(|o| {
244 String::from_utf8_lossy(&o.stdout)
245 .split_whitespace()
246 .filter_map(|s| s.parse::<u32>().ok())
247 .collect()
248 })
249 .unwrap_or_default()
250 }
251 #[cfg(not(any(unix, windows)))]
252 {
253 let _ = pattern;
254 Vec::new()
255 }
256}
257
258/// Return the command line of a specific pid, or `None` if the pid
259/// is missing / unreadable / exited between query and answer.
260///
261/// v0.14.2 (#162 diagnostic, post-supervisor #170): when `wire status`
262/// surfaces orphan pids, the operator wants to know "which session
263/// is that daemon serving?" without grepping `ps` themselves —
264/// closes the launchd-vs-session-isolation diagnostic gap honey-pine
265/// burned multiple sessions on.
266///
267/// - Linux: read `/proc/<pid>/cmdline` (NUL-separated, replace with spaces).
268/// - macOS / BSD: `ps -p <pid> -o command=` (no header, single column).
269/// - Windows: PowerShell CIM `Get-CimInstance Win32_Process | Where
270/// {$_.ProcessId -eq <pid>} | Select CommandLine`.
271///
272/// Conservative on failure: returns `None` rather than synthesizing a
273/// placeholder. Callers should treat None as "annotation unavailable",
274/// not "process is dead" — `process_alive` is the liveness oracle.
275pub fn pid_cmdline(pid: u32) -> Option<String> {
276 #[cfg(target_os = "linux")]
277 {
278 let path = format!("/proc/{pid}/cmdline");
279 let bytes = std::fs::read(&path).ok()?;
280 // `/proc/<pid>/cmdline` is NUL-separated argv. Convert NULs to
281 // spaces for human-readable output; trim trailing NUL.
282 let s: String = bytes
283 .into_iter()
284 .map(|b| if b == 0 { b' ' } else { b })
285 .map(|b| b as char)
286 .collect();
287 let trimmed = s.trim().to_string();
288 if trimmed.is_empty() {
289 None
290 } else {
291 Some(trimmed)
292 }
293 }
294 #[cfg(all(unix, not(target_os = "linux")))]
295 {
296 let out = Command::new("ps")
297 .args(["-p", &pid.to_string(), "-o", "command="])
298 .output()
299 .ok()?;
300 if !out.status.success() {
301 return None;
302 }
303 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
304 if s.is_empty() { None } else { Some(s) }
305 }
306 #[cfg(windows)]
307 {
308 let ps = format!(
309 "Get-CimInstance Win32_Process | \
310 Where-Object {{ $_.ProcessId -eq {pid} }} | \
311 Select-Object -ExpandProperty CommandLine"
312 );
313 let mut cmd = Command::new("powershell.exe");
314 cmd.args(["-NoProfile", "-NonInteractive", "-Command", &ps]);
315 let out = run_with_timeout(cmd, platform_shell_timeout())?;
316 if !out.status.success() {
317 return None;
318 }
319 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
320 if s.is_empty() { None } else { Some(s) }
321 }
322 #[cfg(not(any(unix, windows)))]
323 {
324 let _ = pid;
325 None
326 }
327}
328
329/// Parse `--session <name>` from a wire daemon command line. Returns
330/// `None` if not present. v0.14.2 (#170 supervisor pairs a `--session
331/// <name>` arg with the WIRE_HOME the daemon serves; this extracts it
332/// for orphan-pid diagnostic display).
333pub fn parse_session_arg(cmdline: &str) -> Option<&str> {
334 let parts: Vec<&str> = cmdline.split_whitespace().collect();
335 let i = parts.iter().position(|p| *p == "--session")?;
336 parts.get(i + 1).copied()
337}
338
339/// Signal a pid to exit. Returns true on successful dispatch (NOT on
340/// confirmed exit — poll [`process_alive`] for that). `force=true` is
341/// SIGKILL / `taskkill /F`; `force=false` is SIGTERM / `taskkill`
342/// (graceful).
343///
344/// Windows note: we pass `/T` so the whole process tree dies, not just
345/// the root. The daemon's `wire daemon` invocation is single-process
346/// today but the relay-server spawns hyper worker threads; `/T` is
347/// the safe default.
348pub fn kill_process(pid: u32, force: bool) -> bool {
349 #[cfg(unix)]
350 {
351 let sig = if force { "-9" } else { "-15" };
352 Command::new("kill")
353 .args([sig, &pid.to_string()])
354 .stdin(std::process::Stdio::null())
355 .stdout(std::process::Stdio::null())
356 .stderr(std::process::Stdio::null())
357 .status()
358 .map(|s| s.success())
359 .unwrap_or(false)
360 }
361 #[cfg(windows)]
362 {
363 let pid_str = pid.to_string();
364 let mut args: Vec<&str> = vec!["/PID", &pid_str, "/T"];
365 if force {
366 args.push("/F");
367 }
368 Command::new("taskkill.exe")
369 .args(&args)
370 .stdin(std::process::Stdio::null())
371 .stdout(std::process::Stdio::null())
372 .stderr(std::process::Stdio::null())
373 .status()
374 .map(|s| s.success())
375 .unwrap_or(false)
376 }
377 #[cfg(not(any(unix, windows)))]
378 {
379 let _ = (pid, force);
380 false
381 }
382}
383
384/// Resolve the path of the currently-running executable, robust to the Linux
385/// kernel's `(deleted)` marker.
386///
387/// When a running binary is replaced *in place* — e.g. `cargo install
388/// slancha-wire` unlinks and recreates `~/.cargo/bin/wire` while `wire upgrade`
389/// is still running — the kernel appends a literal ` (deleted)` suffix to
390/// `/proc/self/exe`. That suffix marks the unlinked inode; it is NOT part of
391/// the path. [`std::env::current_exe`] surfaces it verbatim, and writing it
392/// into a systemd `ExecStart=` / launchd program path corrupts the unit
393/// (`error: unrecognized subcommand '(deleted)'`, the unit then flaps forever).
394///
395/// This strips a trailing ` (deleted)` so callers get the real install path,
396/// which the in-place replacement has already recreated on disk. Issues #274,
397/// #276.
398pub fn current_exe_resolved() -> std::io::Result<std::path::PathBuf> {
399 Ok(strip_deleted_suffix(&std::env::current_exe()?))
400}
401
402/// Pure inner of [`current_exe_resolved`]: strip a trailing ` (deleted)` kernel
403/// marker from an exe path. Only the exact trailing ` (deleted)` token (leading
404/// space included) is removed — a path that merely contains the text, or a real
405/// filename ending in `(deleted)` without the kernel's leading space, is left
406/// untouched. Testable without an actually-unlinked binary.
407pub fn strip_deleted_suffix(p: &std::path::Path) -> std::path::PathBuf {
408 match p.to_string_lossy().strip_suffix(" (deleted)") {
409 Some(stripped) => std::path::PathBuf::from(stripped),
410 None => p.to_path_buf(),
411 }
412}
413
414/// Raw, stable machine identifier bytes for the same-machine attestation
415/// fingerprint (RFC-001 amendment #182, `same_machine::machine_fingerprint`).
416///
417/// - **Linux:** `/etc/machine-id` (systemd), falling back to
418/// `/var/lib/dbus/machine-id`.
419/// - **macOS:** `IOPlatformUUID` from `ioreg -rd1 -c IOPlatformExpertDevice`.
420/// - **Windows:** `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid`
421/// (`reg query`).
422///
423/// Returns `None` on any read failure — the caller omits the attestation and
424/// the session still functions, it just can't join the same-machine lane
425/// (fail-closed, §A). The bytes are used only as hash input; their exact
426/// encoding is irrelevant as long as it is stable for a given machine.
427pub fn machine_id_raw() -> Option<Vec<u8>> {
428 #[cfg(target_os = "linux")]
429 {
430 for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
431 if let Ok(s) = std::fs::read_to_string(path) {
432 let t = s.trim();
433 if !t.is_empty() {
434 return Some(t.as_bytes().to_vec());
435 }
436 }
437 }
438 None
439 }
440 #[cfg(target_os = "macos")]
441 {
442 let out = Command::new("ioreg")
443 .args(["-rd1", "-c", "IOPlatformExpertDevice"])
444 .output()
445 .ok()?;
446 if !out.status.success() {
447 return None;
448 }
449 let text = String::from_utf8_lossy(&out.stdout);
450 parse_ioreg_platform_uuid(&text).map(|s| s.into_bytes())
451 }
452 #[cfg(windows)]
453 {
454 let mut cmd = Command::new("reg.exe");
455 cmd.args([
456 "query",
457 r"HKLM\SOFTWARE\Microsoft\Cryptography",
458 "/v",
459 "MachineGuid",
460 ]);
461 let out = run_with_timeout(cmd, platform_shell_timeout())?;
462 if !out.status.success() {
463 return None;
464 }
465 let text = String::from_utf8_lossy(&out.stdout);
466 parse_reg_machine_guid(&text).map(|s| s.into_bytes())
467 }
468 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
469 {
470 None
471 }
472}
473
474/// Stable per-OS-user identifier bytes — the salt that keeps two different
475/// users on one shared host (same `machine_id`) from cross-pairing (#182 §S1).
476///
477/// - **Unix:** `id -u` (the numeric uid). Shelled out rather than pulling in a
478/// `libc` dependency, matching this module's existing shell-out idiom.
479/// - **Windows:** the current user's SID from `whoami /user`.
480///
481/// `None` on read failure (fail-closed, same as [`machine_id_raw`]).
482pub fn os_user_id_bytes() -> Option<Vec<u8>> {
483 #[cfg(unix)]
484 {
485 let out = Command::new("id").arg("-u").output().ok()?;
486 if !out.status.success() {
487 return None;
488 }
489 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
490 if s.is_empty() {
491 None
492 } else {
493 Some(s.into_bytes())
494 }
495 }
496 #[cfg(windows)]
497 {
498 let mut cmd = Command::new("whoami.exe");
499 cmd.args(["/user", "/fo", "csv", "/nh"]);
500 let out = run_with_timeout(cmd, platform_shell_timeout())?;
501 if !out.status.success() {
502 return None;
503 }
504 let text = String::from_utf8_lossy(&out.stdout);
505 parse_whoami_sid(&text).map(|s| s.into_bytes())
506 }
507 #[cfg(not(any(unix, windows)))]
508 {
509 None
510 }
511}
512
513/// Extract the `IOPlatformUUID` value from `ioreg` output. The line looks like
514/// ` "IOPlatformUUID" = "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"`. Hoisted +
515/// tested on every platform so the parse is locked without a macOS runner.
516#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
517fn parse_ioreg_platform_uuid(text: &str) -> Option<String> {
518 let line = text.lines().find(|l| l.contains("IOPlatformUUID"))?;
519 let after = line.split_once('=')?.1.trim();
520 let unquoted = after.trim_matches('"').trim();
521 if unquoted.is_empty() {
522 None
523 } else {
524 Some(unquoted.to_string())
525 }
526}
527
528/// Extract `MachineGuid` from `reg query` output. The value line looks like
529/// ` MachineGuid REG_SZ DDDDDDDD-DDDD-...`. Tested on every platform.
530#[cfg_attr(not(windows), allow(dead_code))]
531fn parse_reg_machine_guid(text: &str) -> Option<String> {
532 let line = text.lines().find(|l| l.contains("MachineGuid"))?;
533 // Split on REG_SZ; the value is the last whitespace-trimmed token after it.
534 let after = line.split("REG_SZ").nth(1)?.trim();
535 if after.is_empty() {
536 None
537 } else {
538 Some(after.to_string())
539 }
540}
541
542/// Extract the SID from `whoami /user /fo csv /nh` output, a single CSV row
543/// `"<domain>\<user>","S-1-5-21-...."`. Tested on every platform.
544#[cfg_attr(not(windows), allow(dead_code))]
545fn parse_whoami_sid(text: &str) -> Option<String> {
546 let row = text.lines().find(|l| l.contains("S-1-"))?;
547 // The SID is the last quoted field.
548 let sid = row.rsplit(',').next()?.trim().trim_matches('"').trim();
549 if sid.is_empty() {
550 None
551 } else {
552 Some(sid.to_string())
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 #[test]
561 fn strip_deleted_suffix_removes_kernel_marker() {
562 use std::path::Path;
563 // The repro from #274: cargo in-place replace → /proc/self/exe carries
564 // the marker → it must NOT reach the unit's ExecStart.
565 assert_eq!(
566 strip_deleted_suffix(Path::new("/home/admin/.cargo/bin/wire (deleted)")),
567 Path::new("/home/admin/.cargo/bin/wire")
568 );
569 }
570
571 #[test]
572 fn strip_deleted_suffix_leaves_clean_path_untouched() {
573 use std::path::Path;
574 assert_eq!(
575 strip_deleted_suffix(Path::new("/usr/local/bin/wire")),
576 Path::new("/usr/local/bin/wire")
577 );
578 }
579
580 #[test]
581 fn strip_deleted_suffix_only_strips_exact_trailing_token() {
582 use std::path::Path;
583 // No leading space → not the kernel marker shape → leave alone.
584 assert_eq!(
585 strip_deleted_suffix(Path::new("/opt/wire(deleted)")),
586 Path::new("/opt/wire(deleted)")
587 );
588 }
589
590 #[test]
591 fn cmdline_role_strips_wire_prefix() {
592 // Locks the Windows .exe-match logic on every platform's CI: the role
593 // is what we match against `wire.exe daemon`, not the full pattern.
594 assert_eq!(cmdline_role("wire daemon"), "daemon");
595 assert_eq!(cmdline_role("wire relay-server"), "relay-server");
596 // No `wire ` prefix → unchanged (custom patterns pass through).
597 assert_eq!(cmdline_role("daemon"), "daemon");
598 assert_eq!(cmdline_role("relay-server"), "relay-server");
599 }
600
601 #[test]
602 fn process_alive_returns_true_for_self() {
603 // Our own pid is alive by definition.
604 let me = std::process::id();
605 assert!(
606 process_alive(me),
607 "process_alive should return true for self pid {me}"
608 );
609 }
610
611 #[test]
612 fn process_alive_returns_false_for_clearly_dead_pid() {
613 // pid 0 is reserved on every Unix; on Windows it's the
614 // "System Idle Process" pseudo-pid and tasklist won't list
615 // it under a numeric filter. Either way: should report dead.
616 // Use a high pid that's astronomically unlikely to be alive
617 // to dodge the pid=0 edge case ambiguity on Windows.
618 let dead = 4_000_000_001;
619 assert!(
620 !process_alive(dead),
621 "process_alive should return false for synthetic dead pid {dead}"
622 );
623 }
624
625 #[test]
626 fn parse_session_arg_extracts_following_value() {
627 assert_eq!(
628 parse_session_arg("wire daemon --session slancha-mesh --interval 5"),
629 Some("slancha-mesh")
630 );
631 assert_eq!(
632 parse_session_arg("wire daemon --interval 5 --session wire-dev"),
633 Some("wire-dev")
634 );
635 // Mid-cmdline + extra whitespace is fine — split_whitespace handles it.
636 assert_eq!(
637 parse_session_arg("/path/to/wire daemon --session foo"),
638 Some("foo")
639 );
640 }
641
642 #[test]
643 fn parse_session_arg_returns_none_without_flag() {
644 assert_eq!(parse_session_arg("wire daemon --interval 5"), None);
645 // Bare `wire daemon --all-sessions` is the supervisor itself —
646 // it doesn't carry a single `--session`. Operators reading the
647 // supervisor's cmdline should see no annotation, not a
648 // misleading session attribution.
649 assert_eq!(
650 parse_session_arg("wire daemon --all-sessions --interval 5"),
651 None
652 );
653 // Empty input is safe.
654 assert_eq!(parse_session_arg(""), None);
655 }
656
657 #[test]
658 fn parse_session_arg_returns_none_when_flag_is_last_token() {
659 // `--session` at end with no value following → None, not a panic.
660 assert_eq!(parse_session_arg("wire daemon --session"), None);
661 }
662
663 #[test]
664 fn pid_cmdline_returns_something_for_self() {
665 // Cross-platform sanity: our own process must have a cmdline.
666 // We can't assert exact content (test runner cmdlines vary) —
667 // just that it returns Some and is non-empty.
668 let me = std::process::id();
669 let cmd = pid_cmdline(me);
670 assert!(
671 cmd.is_some() && !cmd.as_ref().unwrap().is_empty(),
672 "pid_cmdline(self) should return a non-empty cmdline, got {cmd:?}"
673 );
674 }
675
676 #[test]
677 fn pid_cmdline_returns_none_for_dead_pid() {
678 // Use the same astronomically-unlikely pid pattern as
679 // process_alive_returns_false_for_clearly_dead_pid above.
680 let dead = 4_000_000_003;
681 assert_eq!(
682 pid_cmdline(dead),
683 None,
684 "pid_cmdline should return None for synthetic dead pid"
685 );
686 }
687
688 #[test]
689 fn kill_process_on_nonexistent_pid_returns_false_or_noop() {
690 // Asserting on the return value is brittle because `kill -15`
691 // against a missing pid returns 1 on linux but 0 on some
692 // BSDs. The contract is "does not panic" — that alone is
693 // worth a test, given the cfg-gated dispatch.
694 let dead = 4_000_000_002;
695 let _ = kill_process(dead, false);
696 }
697
698 // ---------- #284.1: run_with_timeout ----------
699
700 use std::time::Instant;
701
702 #[test]
703 fn run_with_timeout_returns_some_on_fast_command() {
704 // Pick a tiny command that exists on every platform.
705 #[cfg(unix)]
706 let cmd = {
707 let mut c = Command::new("echo");
708 c.arg("hello");
709 c
710 };
711 #[cfg(windows)]
712 let cmd = {
713 let mut c = Command::new("cmd.exe");
714 c.args(["/C", "echo hello"]);
715 c
716 };
717 let out = run_with_timeout(cmd, Duration::from_secs(5));
718 assert!(out.is_some(), "echo must complete inside 5s");
719 let out = out.unwrap();
720 assert!(out.status.success());
721 let s = String::from_utf8_lossy(&out.stdout);
722 assert!(
723 s.contains("hello"),
724 "stdout should contain `hello`; got {s:?}"
725 );
726 }
727
728 #[test]
729 fn run_with_timeout_returns_none_and_kills_on_slow_command() {
730 // Sleep WAY past the timeout so we can prove the wrapper
731 // returns inside the timeout window, not at sleep completion.
732 #[cfg(unix)]
733 let cmd = {
734 let mut c = Command::new("sleep");
735 c.arg("60");
736 c
737 };
738 #[cfg(windows)]
739 let cmd = {
740 let mut c = Command::new("powershell.exe");
741 c.args([
742 "-NoProfile",
743 "-NonInteractive",
744 "-Command",
745 "Start-Sleep -Seconds 60",
746 ]);
747 c
748 };
749 let started = Instant::now();
750 let out = run_with_timeout(cmd, Duration::from_millis(500));
751 let elapsed = started.elapsed();
752 assert!(out.is_none(), "slow command must time out, got {out:?}");
753 // Generous upper bound — taskkill / kill spawning takes a beat,
754 // and CI runners are not real-time. The point is "not 60s".
755 assert!(
756 elapsed < Duration::from_secs(10),
757 "must return well inside the wedged child's runtime; elapsed={elapsed:?}"
758 );
759 }
760
761 // ---------- #182: same-machine fingerprint platform parsers ----------
762
763 #[test]
764 fn parse_ioreg_platform_uuid_extracts_value() {
765 let sample = "\
766+-o IOPlatformExpertDevice <class IOPlatformExpertDevice>
767 {
768 \"IOPlatformUUID\" = \"564D5E2F-AAAA-BBBB-CCCC-0123456789AB\"
769 \"IOPlatformSerialNumber\" = \"C02XX\"
770 }
771";
772 assert_eq!(
773 parse_ioreg_platform_uuid(sample),
774 Some("564D5E2F-AAAA-BBBB-CCCC-0123456789AB".to_string())
775 );
776 assert_eq!(parse_ioreg_platform_uuid("no uuid here"), None);
777 }
778
779 #[test]
780 fn parse_reg_machine_guid_extracts_value() {
781 let sample = "\r\n\
782HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography\r\n\
783 MachineGuid REG_SZ 11112222-3333-4444-5555-666677778888\r\n";
784 assert_eq!(
785 parse_reg_machine_guid(sample),
786 Some("11112222-3333-4444-5555-666677778888".to_string())
787 );
788 assert_eq!(parse_reg_machine_guid("no guid"), None);
789 }
790
791 #[test]
792 fn parse_whoami_sid_extracts_last_quoted_field() {
793 let sample = "\"contoso\\\\alice\",\"S-1-5-21-1111111111-2222222222-3333333333-1001\"\r\n";
794 assert_eq!(
795 parse_whoami_sid(sample),
796 Some("S-1-5-21-1111111111-2222222222-3333333333-1001".to_string())
797 );
798 assert_eq!(parse_whoami_sid("no sid"), None);
799 }
800
801 #[test]
802 fn platform_shell_timeout_default_is_5s() {
803 // SAFETY: serial tests + this test only reads / restores its own var.
804 // Save and restore any existing value so a sibling test isn't
805 // perturbed (no global ENV_LOCK in this module).
806 let prev = std::env::var("WIRE_PLATFORM_TIMEOUT_SECS").ok();
807 unsafe { std::env::remove_var("WIRE_PLATFORM_TIMEOUT_SECS") };
808 assert_eq!(platform_shell_timeout(), Duration::from_secs(5));
809 unsafe { std::env::set_var("WIRE_PLATFORM_TIMEOUT_SECS", "12") };
810 assert_eq!(platform_shell_timeout(), Duration::from_secs(12));
811 // Restore.
812 match prev {
813 Some(v) => unsafe { std::env::set_var("WIRE_PLATFORM_TIMEOUT_SECS", v) },
814 None => unsafe { std::env::remove_var("WIRE_PLATFORM_TIMEOUT_SECS") },
815 }
816 }
817}