Skip to main content

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}