Skip to main content

runner_manager_platform/
host_fitness.rs

1//! Operating-system primitives for `host doctor` and `host prepare`.
2//!
3//! The check registry, its policy and its wording live in the CLI crate
4//! (`cli::doctor`); this module is only the part that has to talk to an
5//! operating system: a few read-only probes, the handful of privileged writes
6//! `host prepare` makes, the one way each platform asks a person for
7//! administrator rights, and the record the daemon leaves when it refuses to
8//! start runners on an unfit host.
9//!
10//! # Every probe is read-only and needs no privilege
11//!
12//! A probe that cannot answer without administrator rights says so (an
13//! [`RegistryError::AccessDenied`], for instance) instead of guessing, and the
14//! caller reports the check as unknown. Nothing here writes outside the three
15//! `write_*` functions and [`run_elevated`].
16
17use std::ffi::{OsStr, OsString};
18use std::path::{Path, PathBuf};
19
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22
23use crate::paths::AppPaths;
24
25// ---------------------------------------------------------------------------
26// Privilege
27// ---------------------------------------------------------------------------
28
29/// Whether this process already holds administrator rights: an elevated token
30/// on Windows, effective uid 0 elsewhere.
31#[must_use]
32pub fn is_elevated() -> bool {
33    sys::is_elevated()
34}
35
36/// How an elevated relaunch ended.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ElevationOutcome {
39    /// The elevated process ran and exited with this code.
40    Exited(i32),
41    /// The person declined the prompt (UAC "No", a cancelled password
42    /// dialog, or three wrong `sudo` passwords).
43    Refused,
44    /// This session cannot show a prompt at all (no desktop, no terminal, no
45    /// `sudo`), so nothing was asked.
46    Unavailable(String),
47}
48
49/// Runs `program args` with administrator rights, asking the person once, and
50/// waits for it.
51///
52/// * **Windows:** `ShellExecuteExW` with the `runas` verb, which shows the UAC
53///   prompt. The child's window is hidden; it reports through a file its
54///   caller names on the command line.
55/// * **macOS:** `sudo` when `terminal` is true (the password is asked for on
56///   this terminal), otherwise `osascript … with administrator privileges`,
57///   which shows the system's password dialog.
58/// * **Linux:** `sudo` when `terminal` is true; otherwise unavailable.
59#[must_use]
60pub fn run_elevated(program: &Path, args: &[OsString], terminal: bool) -> ElevationOutcome {
61    sys::run_elevated(program, args, terminal)
62}
63
64/// Whether somebody can answer an administrator prompt from this process.
65///
66/// On Windows the prompt is UAC's, a dialog on the desktop of this process's
67/// session, so a terminal has nothing to do with it: what matters is whether
68/// the session has a desktop somebody sits at. Every session but session 0
69/// does; session 0 is where services, tasks that run whether or not anybody is
70/// signed in, and OpenSSH logins run, and a dialog there is seen by nobody. A
71/// command started on the desktop by a script or an agent, with no terminal at
72/// all, used to be refused the prompt it could have shown.
73///
74/// Elsewhere it is `terminal`: whether stdin and stderr are a terminal, where
75/// `sudo` asks for the password.
76#[must_use]
77pub fn can_answer_elevation_prompt(terminal: bool) -> bool {
78    prompt_answerable(sys::session_id(), terminal)
79}
80
81/// [`can_answer_elevation_prompt`] from what was found: the Windows session
82/// when there is one to ask about, otherwise the terminal.
83fn prompt_answerable(session: Option<u32>, terminal: bool) -> bool {
84    session.map_or(terminal, |session| session != 0)
85}
86
87/// Quotes one argument for a POSIX shell.
88#[must_use]
89pub fn quote_posix_argument(argument: &str) -> String {
90    format!("'{}'", argument.replace('\'', "'\\''"))
91}
92
93/// The AppleScript that runs `command` (already a shell command line) with
94/// administrator privileges.
95#[must_use]
96pub fn administrator_applescript(command: &str) -> String {
97    let escaped = command.replace('\\', "\\\\").replace('"', "\\\"");
98    format!("do shell script \"{escaped}\" with administrator privileges")
99}
100
101// ---------------------------------------------------------------------------
102// Read-only facts
103// ---------------------------------------------------------------------------
104
105/// Total physical memory, in bytes.
106#[must_use]
107pub fn physical_memory_bytes() -> Option<u64> {
108    sys::physical_memory_bytes()
109}
110
111/// The Windows system directory, from the kernel rather than from the
112/// writable `%SystemRoot%`: an elevated fix starts `powershell.exe` from it.
113///
114/// # Errors
115/// The system's error when the directory cannot be reported.
116#[cfg(windows)]
117pub fn system_directory() -> std::io::Result<PathBuf> {
118    crate::runner_root::windows_system_directory().map(PathBuf::from)
119}
120
121/// Replaces `path` with `bytes` atomically: a temporary file beside it, flushed
122/// to disk, then renamed over it, so a reader never sees half a file.
123///
124/// # Errors
125/// The I/O error of any step.
126pub fn write_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
127    use std::io::Write as _;
128    let parent = path.parent().unwrap_or_else(|| Path::new("."));
129    std::fs::create_dir_all(parent)?;
130    let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
131    temporary.write_all(bytes)?;
132    temporary.as_file().sync_all()?;
133    temporary
134        .persist(path)
135        .map(|_| ())
136        .map_err(|error| error.error)
137}
138
139/// How a bounded look at a directory went.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum Responsiveness {
142    /// It answered a `stat` and the first entry of a listing in time.
143    Responds,
144    /// It did not answer within the deadline: a hung volume (a stalled USB or
145    /// NVMe enclosure, a dead network mount).
146    Hung,
147    /// Its metadata answered, and listing it did not within the deadline. On
148    /// macOS that is what a pending privacy question looks like: reading a
149    /// folder on a removable or network volume waits while "would like to
150    /// access files on a removable volume" is on the desktop, while `stat`
151    /// does not. A stalled disk usually stalls the `stat` too.
152    ListingBlocked,
153    /// The system refused to let this process list it (`EPERM`). On macOS
154    /// that is a privacy setting this process was refused, not file
155    /// permissions, which answer `EACCES`.
156    NotPermitted(String),
157    /// It answered with an error.
158    Failed(String),
159}
160
161/// A [`directory_responds`] probe still blocked: its path, and whether its
162/// metadata had answered (so the listing is what blocks).
163type InFlight = (PathBuf, std::sync::Arc<std::sync::atomic::AtomicBool>);
164
165/// Probes still blocked, so a volume that stays hung costs one stuck thread
166/// rather than one per probe, and a later probe of it reports what the stuck
167/// one is blocked on.
168static PROBES_IN_FLIGHT: std::sync::Mutex<Vec<InFlight>> = std::sync::Mutex::new(Vec::new());
169
170/// The directories a [`directory_responds`] probe is still blocked on.
171#[must_use]
172pub fn probes_in_flight() -> Vec<PathBuf> {
173    PROBES_IN_FLIGHT
174        .lock()
175        .map(|in_flight| in_flight.iter().map(|(path, _)| path.clone()).collect())
176        .unwrap_or_default()
177}
178
179/// How a probe that has not answered is reported.
180fn unanswered(listing: &std::sync::atomic::AtomicBool) -> Responsiveness {
181    if listing.load(std::sync::atomic::Ordering::Acquire) {
182        Responsiveness::ListingBlocked
183    } else {
184        Responsiveness::Hung
185    }
186}
187
188/// Whether `directory` (or, when it does not exist yet, its nearest existing
189/// ancestor) answers a `stat` and a one-entry listing within `deadline`.
190///
191/// The look runs on its own thread because a call into a hung filesystem does
192/// not return and cannot be cancelled. That thread is left blocked when the
193/// deadline passes, and while it is, later probes of the same path report
194/// [`Responsiveness::Hung`] at once instead of stacking more threads on it.
195#[must_use]
196pub fn directory_responds(directory: &Path, deadline: std::time::Duration) -> Responsiveness {
197    let path = directory.to_path_buf();
198    // Set once the metadata has answered, so a probe still blocked can say
199    // whether it was the `stat` or the listing that never came back.
200    let listing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
201    {
202        let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() else {
203            return Responsiveness::Failed("the probe registry is poisoned".into());
204        };
205        if let Some((_, stuck)) = in_flight.iter().find(|(stuck, _)| *stuck == path) {
206            return unanswered(stuck);
207        }
208        in_flight.push((path.clone(), std::sync::Arc::clone(&listing)));
209    }
210    let (sender, receiver) = std::sync::mpsc::channel();
211    let probed = path.clone();
212    let listing_started = std::sync::Arc::clone(&listing);
213    let spawned = std::thread::Builder::new()
214        .name("runner-root-probe".into())
215        .spawn(move || {
216            let existing = probed
217                .ancestors()
218                .find(|ancestor| std::fs::symlink_metadata(ancestor).is_ok())
219                .map(Path::to_path_buf);
220            listing_started.store(true, std::sync::atomic::Ordering::Release);
221            let refused = |error: std::io::Error| {
222                if is_not_permitted(&error) {
223                    Responsiveness::NotPermitted(error.to_string())
224                } else {
225                    Responsiveness::Failed(error.to_string())
226                }
227            };
228            let answer = match existing {
229                None => Responsiveness::Failed(format!("{} does not exist", probed.display())),
230                Some(existing) => match std::fs::read_dir(&existing) {
231                    Ok(mut entries) => match entries.next() {
232                        Some(Err(error)) => refused(error),
233                        _ => Responsiveness::Responds,
234                    },
235                    Err(error) => refused(error),
236                },
237            };
238            if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
239                in_flight.retain(|(candidate, _)| *candidate != probed);
240            }
241            let _ = sender.send(answer);
242        });
243    if let Err(error) = spawned {
244        if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
245            in_flight.retain(|(candidate, _)| *candidate != path);
246        }
247        return Responsiveness::Failed(error.to_string());
248    }
249    receiver
250        .recv_timeout(deadline)
251        .unwrap_or_else(|_| unanswered(&listing))
252}
253
254/// `EPERM`, which a privacy refusal answers, as opposed to `EACCES`.
255fn is_not_permitted(error: &std::io::Error) -> bool {
256    #[cfg(unix)]
257    {
258        error.raw_os_error() == Some(libc::EPERM)
259    }
260    #[cfg(not(unix))]
261    {
262        let _ = error;
263        false
264    }
265}
266
267/// Whether this process can create a symbolic link inside `directory`.
268///
269/// Measured rather than inferred: a link is created and removed again. On
270/// Windows this answers for the account and token this process runs with,
271/// which for the daemon is exactly the account runners inherit; a file link
272/// is what `npm`, `pnpm` and `setup-bun` create, and it needs either
273/// `SeCreateSymbolicLinkPrivilege` or Developer Mode.
274///
275/// # Errors
276/// The I/O error when the probe's target file cannot even be created, which
277/// says nothing about links.
278pub fn can_create_symlink(directory: &Path) -> std::io::Result<bool> {
279    std::fs::create_dir_all(directory)?;
280    let unique = format!(
281        ".rm-symlink-probe-{}-{}",
282        std::process::id(),
283        uuid::Uuid::new_v4().simple()
284    );
285    let target = directory.join(format!("{unique}.target"));
286    let link = directory.join(format!("{unique}.link"));
287    std::fs::write(&target, b"")?;
288    let created = symlink_file(&target, &link);
289    let _ = std::fs::remove_file(&link);
290    let _ = std::fs::remove_file(&target);
291    Ok(created.is_ok())
292}
293
294#[cfg(windows)]
295fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
296    std::os::windows::fs::symlink_file(target, link)
297}
298
299#[cfg(unix)]
300fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
301    std::os::unix::fs::symlink(target, link)
302}
303
304/// Finds `tool` on `path` the way a shell would, including `PATHEXT` on
305/// Windows. A name holding a path separator is never searched.
306#[must_use]
307pub fn find_on_path(tool: &str, path: &OsStr) -> Option<PathBuf> {
308    if cfg!(windows) {
309        return find_on_windows_path(tool, path, std::env::var_os("PATHEXT").as_deref());
310    }
311    if tool.is_empty() || tool.contains(['/', '\\']) {
312        return None;
313    }
314    std::env::split_paths(path)
315        .filter(|dir| !dir.as_os_str().is_empty())
316        .map(|dir| dir.join(tool))
317        .find(|candidate| is_executable(candidate))
318}
319
320/// What `cmd.exe` would run for `tool`: the first `path` directory, in order,
321/// holding `tool` with one of the `pathext` extensions, tried in `pathext`
322/// order within each directory. Compiled on every platform so every CI leg
323/// tests it; [`find_on_path`] calls it on Windows.
324///
325/// `std::process::Command` cannot do this itself: given a bare name it looks
326/// only for `<name>.exe`, so npm, which ships as `npm.cmd`, reads as missing.
327///
328/// Departures from `cmd.exe`, each so that only a file the operator put on
329/// their `PATH` on purpose can be the answer and `Command` can run it with its
330/// arguments intact:
331/// - the current directory and relative `PATH` entries are not searched;
332/// - only `.exe`, `.com`, `.cmd` and `.bat` are tried (a `.ps1` or `.js` in
333///   `PATHEXT` is skipped), lower-cased so `Command` recognises a batch file;
334/// - a name is tried as it is only when it already ends in one of those
335///   extensions: `CreateProcess` never runs an extensionless file, and
336///   Node.js ships a POSIX `npm` script beside `npm.cmd`.
337#[must_use]
338pub fn find_on_windows_path(tool: &str, path: &OsStr, pathext: Option<&OsStr>) -> Option<PathBuf> {
339    const RUNNABLE: [&str; 4] = [".exe", ".com", ".cmd", ".bat"];
340    if tool.is_empty() || tool.contains(['/', '\\']) {
341        return None;
342    }
343    let pathext = pathext
344        .and_then(OsStr::to_str)
345        .filter(|value| !value.trim().is_empty())
346        .unwrap_or(".COM;.EXE;.BAT;.CMD");
347    let bare = Path::new(tool)
348        .extension()
349        .and_then(OsStr::to_str)
350        .is_some_and(|extension| {
351            RUNNABLE
352                .iter()
353                .any(|runnable| runnable[1..].eq_ignore_ascii_case(extension))
354        })
355        .then_some("");
356    let extensions: Vec<&str> = bare
357        .into_iter()
358        .chain(pathext.split(';').filter_map(|extension| {
359            RUNNABLE
360                .into_iter()
361                .find(|runnable| runnable.eq_ignore_ascii_case(extension.trim()))
362        }))
363        .collect();
364    std::env::split_paths(path)
365        .filter(|dir| dir.is_absolute())
366        .find_map(|dir| {
367            extensions.iter().find_map(|extension| {
368                let candidate = dir.join(format!("{tool}{extension}"));
369                candidate.is_file().then_some(candidate)
370            })
371        })
372}
373
374#[cfg(unix)]
375fn is_executable(path: &Path) -> bool {
376    use std::os::unix::fs::PermissionsExt as _;
377    std::fs::metadata(path)
378        .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
379}
380
381#[cfg(windows)]
382fn is_executable(path: &Path) -> bool {
383    std::fs::metadata(path).is_ok_and(|meta| meta.is_file())
384}
385
386// ---------------------------------------------------------------------------
387// The Windows registry (HKEY_LOCAL_MACHINE only)
388// ---------------------------------------------------------------------------
389
390/// Why a registry read or write failed.
391#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
392pub enum RegistryError {
393    /// The key exists and this token may not read (or write) it. Defender's
394    /// exclusion list answers this to every non-administrator.
395    #[error("access denied")]
396    AccessDenied,
397    /// Any other failure, with the system's code.
398    #[error("{0}")]
399    Other(String),
400}
401
402/// Reads a `REG_DWORD` under `HKLM\subkey`; `None` when the key or value is
403/// absent.
404///
405/// # Errors
406/// [`RegistryError`] when it exists and cannot be read.
407pub fn read_registry_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
408    sys::read_dword(subkey, value)
409}
410
411/// Reads a `REG_SZ` or `REG_EXPAND_SZ` (unexpanded) under `HKLM\subkey`.
412///
413/// # Errors
414/// [`RegistryError`] when it exists and cannot be read.
415pub fn read_registry_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
416    sys::read_string(subkey, value)
417}
418
419/// The names of every value under `HKLM\subkey`; an empty list when the key
420/// does not exist.
421///
422/// # Errors
423/// [`RegistryError::AccessDenied`] when the key may not be read.
424pub fn registry_value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
425    sys::value_names(subkey)
426}
427
428/// Writes (`Some`) or deletes (`None`) a `REG_DWORD` under `HKLM\subkey`,
429/// creating the key when needed. Needs administrator rights.
430///
431/// # Errors
432/// [`RegistryError`] when the write is refused.
433pub fn write_registry_dword(
434    subkey: &str,
435    value: &str,
436    data: Option<u32>,
437) -> Result<(), RegistryError> {
438    sys::write_dword(subkey, value, data)
439}
440
441/// Writes (`Some`) or deletes (`None`) a `REG_SZ` under `HKLM\subkey`. Needs
442/// administrator rights.
443///
444/// # Errors
445/// [`RegistryError`] when the write is refused.
446pub fn write_registry_string(
447    subkey: &str,
448    value: &str,
449    data: Option<&str>,
450) -> Result<(), RegistryError> {
451    sys::write_string(subkey, value, data)
452}
453
454// ---------------------------------------------------------------------------
455// The daemon's host-unfit record
456// ---------------------------------------------------------------------------
457
458/// The file under `state/` the daemon keeps while it refuses to start runners.
459pub const HOST_UNFIT_FILE: &str = "host-unfit.json";
460
461/// How often the daemon re-evaluates the required checks, re-stamping the
462/// record while they fail.
463pub const DAEMON_RECHECK: std::time::Duration = std::time::Duration::from_secs(5 * 60);
464
465/// How long the record counts as a refusal in force: three rechecks, so one
466/// slow evaluation does not hide it, and a record nobody re-stamps for longer
467/// was left behind by a daemon that stopped while refusing.
468pub const HOST_UNFIT_RECORD_FRESH: std::time::Duration =
469    std::time::Duration::from_secs(3 * DAEMON_RECHECK.as_secs());
470const HOST_UNFIT_SCHEMA_VERSION: u32 = 1;
471
472/// What the daemon recorded the last time a required host check failed.
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
474pub struct HostUnfitRecord {
475    pub schema_version: u32,
476    /// The first moment of the ongoing refusal.
477    pub since: DateTime<Utc>,
478    /// When the checks were last evaluated.
479    pub checked_at: DateTime<Utc>,
480    /// The ids of the required checks that failed.
481    pub checks: Vec<String>,
482    /// What each of them found, from the daemon's own session, and what fixes
483    /// it. Empty in a record an older daemon wrote.
484    #[serde(default)]
485    pub findings: Vec<UnfitFinding>,
486}
487
488/// One failing required check, as the daemon saw it.
489#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
490pub struct UnfitFinding {
491    pub id: String,
492    pub detail: String,
493    #[serde(default)]
494    pub remedy: Option<String>,
495}
496
497impl HostUnfitRecord {
498    /// One sentence per failing check, with its remedy.
499    #[must_use]
500    pub fn describe(&self) -> String {
501        if self.findings.is_empty() {
502            return self.checks.join(", ");
503        }
504        self.findings
505            .iter()
506            .map(|finding| match &finding.remedy {
507                Some(remedy) => format!("{}: {}. Fix: {remedy}", finding.id, finding.detail),
508                None => format!("{}: {}", finding.id, finding.detail),
509            })
510            .collect::<Vec<_>>()
511            .join("; ")
512    }
513}
514
515fn host_unfit_path(paths: &AppPaths) -> PathBuf {
516    paths.state_dir().join(HOST_UNFIT_FILE)
517}
518
519/// Records that required checks failed, keeping the first moment of an
520/// ongoing refusal.
521///
522/// # Errors
523/// A description of the write failure.
524pub fn record_host_unfit(
525    paths: &AppPaths,
526    findings: &[UnfitFinding],
527    at: DateTime<Utc>,
528) -> Result<(), String> {
529    let since = host_unfit(paths)
530        .ok()
531        .flatten()
532        .map_or(at, |record| record.since);
533    let record = HostUnfitRecord {
534        schema_version: HOST_UNFIT_SCHEMA_VERSION,
535        since,
536        checked_at: at,
537        checks: findings.iter().map(|finding| finding.id.clone()).collect(),
538        findings: findings.to_vec(),
539    };
540    let path = host_unfit_path(paths);
541    let text = serde_json::to_vec_pretty(&record).map_err(|error| error.to_string())?;
542    write_atomically(&path, &text)
543        .map_err(|error| format!("cannot write {}: {error}", path.display()))
544}
545
546/// Removes the record once every required check passes.
547///
548/// # Errors
549/// A description of the failure when the record exists and cannot be removed.
550pub fn clear_host_unfit(paths: &AppPaths) -> Result<(), String> {
551    let path = host_unfit_path(paths);
552    match std::fs::remove_file(&path) {
553        Ok(()) => Ok(()),
554        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
555        Err(error) => Err(format!("cannot remove {}: {error}", path.display())),
556    }
557}
558
559/// The daemon's refusal, when one is in force at `now`: recorded, and either
560/// re-stamped within [`HOST_UNFIT_RECORD_FRESH`] or left by a daemon that is
561/// still running and has stalled.
562///
563/// A record nobody re-stamps is a stopped daemon's, not a refusal in force.
564/// But a daemon that stalls stops re-stamping too, and its cause usually
565/// persists: the refusal used to vanish from `status` after fifteen minutes on
566/// a daemon still blocked behind the very question that made the host unfit.
567#[must_use]
568pub fn host_unfit_in_force(paths: &AppPaths, now: DateTime<Utc>) -> Option<HostUnfitRecord> {
569    host_unfit(paths).ok().flatten().filter(|record| {
570        if !crate::wsl::fence::elapsed_at_least(record.checked_at, now, HOST_UNFIT_RECORD_FRESH) {
571            return true;
572        }
573        // Stamped by the daemon that stalled, near its last beat: an older
574        // record is another generation's, and says nothing about this one.
575        match crate::daemon_heartbeat::liveness(paths, now) {
576            crate::daemon_heartbeat::Liveness::Stalled { since, .. } => {
577                !crate::wsl::fence::elapsed_at_least(
578                    record.checked_at,
579                    since,
580                    HOST_UNFIT_RECORD_FRESH,
581                )
582            }
583            _ => false,
584        }
585    })
586}
587
588/// The daemon's current refusal, if it has one.
589///
590/// # Errors
591/// A description of the failure when the file exists and cannot be read.
592pub fn host_unfit(paths: &AppPaths) -> Result<Option<HostUnfitRecord>, String> {
593    let path = host_unfit_path(paths);
594    match std::fs::read_to_string(&path) {
595        Ok(text) => serde_json::from_str(&text)
596            .map(Some)
597            .map_err(|error| format!("cannot parse {}: {error}", path.display())),
598        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
599        Err(error) => Err(format!("cannot read {}: {error}", path.display())),
600    }
601}
602
603// ---------------------------------------------------------------------------
604// Windows
605// ---------------------------------------------------------------------------
606
607#[cfg(windows)]
608mod sys {
609    use std::ffi::OsString;
610    use std::os::windows::ffi::OsStrExt as _;
611    use std::path::Path;
612
613    use windows::Win32::Foundation::{
614        CloseHandle, ERROR_ACCESS_DENIED, ERROR_CANCELLED, ERROR_FILE_NOT_FOUND,
615        ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, HANDLE, WAIT_OBJECT_0, WIN32_ERROR,
616    };
617    use windows::Win32::Security::{
618        GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation, TokenSessionId,
619    };
620    use windows::Win32::System::Registry::{
621        HKEY, HKEY_LOCAL_MACHINE, KEY_READ, KEY_SET_VALUE, REG_DWORD, REG_OPTION_NON_VOLATILE,
622        REG_SZ, RRF_NOEXPAND, RRF_RT_REG_DWORD, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey,
623        RegCreateKeyExW, RegDeleteKeyValueW, RegEnumValueW, RegGetValueW, RegOpenKeyExW,
624        RegSetValueExW,
625    };
626    use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
627    use windows::Win32::System::Threading::{
628        GetCurrentProcess, GetExitCodeProcess, OpenProcessToken, WaitForSingleObject,
629    };
630    use windows::Win32::UI::Shell::{
631        SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW,
632    };
633    use windows::core::{PCWSTR, PWSTR};
634
635    use super::{ElevationOutcome, RegistryError};
636
637    /// `SW_HIDE`. Spelled here rather than pulling in the whole
638    /// `WindowsAndMessaging` module for one integer.
639    const SW_HIDE: i32 = 0;
640    /// The longest an elevated fix batch is waited for. Generous: Defender
641    /// and `git config` answer in seconds, and a person may take a while to
642    /// read the UAC prompt.
643    const ELEVATED_WAIT_MS: u32 = 15 * 60 * 1000;
644
645    fn wide(text: &str) -> Vec<u16> {
646        text.encode_utf16().chain(std::iter::once(0)).collect()
647    }
648
649    fn registry_error(code: WIN32_ERROR) -> RegistryError {
650        if code == ERROR_ACCESS_DENIED {
651            RegistryError::AccessDenied
652        } else {
653            RegistryError::Other(format!("registry error {}", code.0))
654        }
655    }
656
657    /// The Terminal Services session this process runs in.
658    pub(super) fn session_id() -> Option<u32> {
659        let mut token = HANDLE::default();
660        // SAFETY: the pseudo-handle from `GetCurrentProcess` needs no closing;
661        // `token` is a live out-parameter owned by this frame.
662        if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
663            return None;
664        }
665        let mut session = 0u32;
666        let mut returned = 0u32;
667        // SAFETY: `session` is a `u32` owned by this frame, which is what
668        // `TokenSessionId` returns, and its size is the length passed.
669        let queried = unsafe {
670            GetTokenInformation(
671                token,
672                TokenSessionId,
673                Some((&raw mut session).cast()),
674                4,
675                &raw mut returned,
676            )
677        };
678        // SAFETY: `token` was opened above and is closed exactly once.
679        let _ = unsafe { CloseHandle(token) };
680        queried.is_ok().then_some(session)
681    }
682
683    pub(super) fn is_elevated() -> bool {
684        let mut token = HANDLE::default();
685        // SAFETY: the pseudo-handle from `GetCurrentProcess` needs no closing;
686        // `token` is a live out-parameter owned by this frame.
687        if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
688            return false;
689        }
690        let mut elevation = TOKEN_ELEVATION::default();
691        let mut returned = 0u32;
692        // SAFETY: `elevation` is a correctly sized `TOKEN_ELEVATION` owned by
693        // this frame, and its size is the length passed.
694        let queried = unsafe {
695            GetTokenInformation(
696                token,
697                TokenElevation,
698                Some((&raw mut elevation).cast()),
699                u32::try_from(std::mem::size_of::<TOKEN_ELEVATION>()).unwrap_or(4),
700                &raw mut returned,
701            )
702        };
703        // SAFETY: `token` was opened above and is closed exactly once.
704        let _ = unsafe { CloseHandle(token) };
705        queried.is_ok() && elevation.TokenIsElevated != 0
706    }
707
708    pub(super) fn physical_memory_bytes() -> Option<u64> {
709        let mut status = MEMORYSTATUSEX {
710            dwLength: u32::try_from(std::mem::size_of::<MEMORYSTATUSEX>()).ok()?,
711            ..MEMORYSTATUSEX::default()
712        };
713        // SAFETY: `status` is a correctly sized, length-stamped structure
714        // owned by this frame.
715        unsafe { GlobalMemoryStatusEx(&raw mut status) }.ok()?;
716        Some(status.ullTotalPhys)
717    }
718
719    pub(super) fn read_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
720        let subkey = wide(subkey);
721        let value = wide(value);
722        let mut data = 0u32;
723        let mut size = 4u32;
724        // SAFETY: both names are NUL-terminated and outlive the call; `data`
725        // and `size` are live and describe a 4-byte buffer.
726        let code = unsafe {
727            RegGetValueW(
728                HKEY_LOCAL_MACHINE,
729                PCWSTR(subkey.as_ptr()),
730                PCWSTR(value.as_ptr()),
731                RRF_RT_REG_DWORD,
732                None,
733                Some((&raw mut data).cast()),
734                Some(&raw mut size),
735            )
736        };
737        match code {
738            ERROR_SUCCESS => Ok(Some(data)),
739            ERROR_FILE_NOT_FOUND => Ok(None),
740            other => Err(registry_error(other)),
741        }
742    }
743
744    pub(super) fn read_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
745        let subkey = wide(subkey);
746        let value = wide(value);
747        let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ | RRF_NOEXPAND;
748        let mut size = 0u32;
749        // SAFETY: a size query; both names are NUL-terminated and outlive it.
750        let code = unsafe {
751            RegGetValueW(
752                HKEY_LOCAL_MACHINE,
753                PCWSTR(subkey.as_ptr()),
754                PCWSTR(value.as_ptr()),
755                flags,
756                None,
757                None,
758                Some(&raw mut size),
759            )
760        };
761        match code {
762            ERROR_SUCCESS => {}
763            ERROR_FILE_NOT_FOUND => return Ok(None),
764            other => return Err(registry_error(other)),
765        }
766        let mut buffer = vec![0u16; (size as usize).div_ceil(2) + 1];
767        let mut size = u32::try_from(buffer.len() * 2).unwrap_or(u32::MAX);
768        // SAFETY: `buffer` is owned by this frame and `size` is its length in
769        // bytes.
770        let code = unsafe {
771            RegGetValueW(
772                HKEY_LOCAL_MACHINE,
773                PCWSTR(subkey.as_ptr()),
774                PCWSTR(value.as_ptr()),
775                flags,
776                None,
777                Some(buffer.as_mut_ptr().cast()),
778                Some(&raw mut size),
779            )
780        };
781        match code {
782            ERROR_SUCCESS => {
783                let units = (size as usize / 2).min(buffer.len());
784                let text = String::from_utf16_lossy(&buffer[..units]);
785                Ok(Some(text.trim_end_matches('\0').to_owned()))
786            }
787            ERROR_FILE_NOT_FOUND => Ok(None),
788            other => Err(registry_error(other)),
789        }
790    }
791
792    pub(super) fn value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
793        let subkey = wide(subkey);
794        let mut key = HKEY::default();
795        // SAFETY: `subkey` is NUL-terminated and `key` is a live out-parameter.
796        let code = unsafe {
797            RegOpenKeyExW(
798                HKEY_LOCAL_MACHINE,
799                PCWSTR(subkey.as_ptr()),
800                None,
801                KEY_READ,
802                &raw mut key,
803            )
804        };
805        match code {
806            ERROR_SUCCESS => {}
807            ERROR_FILE_NOT_FOUND => return Ok(Vec::new()),
808            other => return Err(registry_error(other)),
809        }
810        let mut names = Vec::new();
811        let mut index = 0u32;
812        let result = loop {
813            // 16,383 characters is the documented maximum value-name length.
814            let mut name = vec![0u16; 16_384];
815            let mut length = u32::try_from(name.len()).unwrap_or(u32::MAX);
816            // SAFETY: `name` is owned by this frame and `length` is its size
817            // in characters; every other out-parameter is omitted.
818            let code = unsafe {
819                RegEnumValueW(
820                    key,
821                    index,
822                    Some(PWSTR(name.as_mut_ptr())),
823                    &raw mut length,
824                    None,
825                    None,
826                    None,
827                    None,
828                )
829            };
830            match code {
831                ERROR_SUCCESS => {
832                    names.push(String::from_utf16_lossy(&name[..length as usize]));
833                    index += 1;
834                }
835                ERROR_NO_MORE_ITEMS => break Ok(names),
836                other => break Err(registry_error(other)),
837            }
838        };
839        // SAFETY: `key` was opened above and is closed exactly once.
840        let _ = unsafe { RegCloseKey(key) };
841        result
842    }
843
844    fn write_value(
845        subkey: &str,
846        value: &str,
847        data: Option<(windows::Win32::System::Registry::REG_VALUE_TYPE, Vec<u8>)>,
848    ) -> Result<(), RegistryError> {
849        let subkey_wide = wide(subkey);
850        let value_wide = wide(value);
851        let Some((kind, bytes)) = data else {
852            // SAFETY: both names are NUL-terminated and outlive the call.
853            let code = unsafe {
854                RegDeleteKeyValueW(
855                    HKEY_LOCAL_MACHINE,
856                    PCWSTR(subkey_wide.as_ptr()),
857                    PCWSTR(value_wide.as_ptr()),
858                )
859            };
860            return match code {
861                ERROR_SUCCESS | ERROR_FILE_NOT_FOUND => Ok(()),
862                other => Err(registry_error(other)),
863            };
864        };
865        let mut key = HKEY::default();
866        // SAFETY: `subkey_wide` is NUL-terminated; `key` is a live
867        // out-parameter; no class, security attributes or disposition.
868        let code = unsafe {
869            RegCreateKeyExW(
870                HKEY_LOCAL_MACHINE,
871                PCWSTR(subkey_wide.as_ptr()),
872                None,
873                PCWSTR::null(),
874                REG_OPTION_NON_VOLATILE,
875                KEY_SET_VALUE,
876                None,
877                &raw mut key,
878                None,
879            )
880        };
881        if code != ERROR_SUCCESS {
882            return Err(registry_error(code));
883        }
884        // SAFETY: `key` is open for `KEY_SET_VALUE`; the value name is
885        // NUL-terminated and `bytes` outlives the call.
886        let code =
887            unsafe { RegSetValueExW(key, PCWSTR(value_wide.as_ptr()), None, kind, Some(&bytes)) };
888        // SAFETY: `key` was opened above and is closed exactly once.
889        let _ = unsafe { RegCloseKey(key) };
890        if code == ERROR_SUCCESS {
891            Ok(())
892        } else {
893            Err(registry_error(code))
894        }
895    }
896
897    pub(super) fn write_dword(
898        subkey: &str,
899        value: &str,
900        data: Option<u32>,
901    ) -> Result<(), RegistryError> {
902        write_value(
903            subkey,
904            value,
905            data.map(|data| (REG_DWORD, data.to_le_bytes().to_vec())),
906        )
907    }
908
909    pub(super) fn write_string(
910        subkey: &str,
911        value: &str,
912        data: Option<&str>,
913    ) -> Result<(), RegistryError> {
914        write_value(
915            subkey,
916            value,
917            data.map(|text| {
918                let bytes = wide(text)
919                    .iter()
920                    .flat_map(|unit| unit.to_le_bytes())
921                    .collect();
922                (REG_SZ, bytes)
923            }),
924        )
925    }
926
927    pub(super) fn run_elevated(
928        program: &Path,
929        args: &[OsString],
930        _terminal: bool,
931    ) -> ElevationOutcome {
932        let file: Vec<u16> = program
933            .as_os_str()
934            .encode_wide()
935            .chain(std::iter::once(0))
936            .collect();
937        let parameters = args
938            .iter()
939            .map(|argument| crate::service::quote_argument(&argument.to_string_lossy()))
940            .collect::<Vec<_>>()
941            .join(" ");
942        let parameters = wide(&parameters);
943        let verb = wide("runas");
944        let mut info = SHELLEXECUTEINFOW {
945            cbSize: u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).unwrap_or(0),
946            fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC,
947            lpVerb: PCWSTR(verb.as_ptr()),
948            lpFile: PCWSTR(file.as_ptr()),
949            lpParameters: PCWSTR(parameters.as_ptr()),
950            nShow: SW_HIDE,
951            ..SHELLEXECUTEINFOW::default()
952        };
953        // SAFETY: every string in `info` is NUL-terminated and outlives the
954        // call; `info` is correctly sized and owned by this frame.
955        if let Err(error) = unsafe { ShellExecuteExW(&raw mut info) } {
956            #[allow(clippy::cast_sign_loss)]
957            let code = (error.code().0 as u32) & 0xffff;
958            return if code == ERROR_CANCELLED.0 {
959                ElevationOutcome::Refused
960            } else {
961                ElevationOutcome::Unavailable(format!(
962                    "Windows could not show the administrator prompt: {}",
963                    error.message()
964                ))
965            };
966        }
967        if info.hProcess.is_invalid() {
968            return ElevationOutcome::Unavailable(
969                "Windows started no elevated process to wait for".into(),
970            );
971        }
972        // SAFETY: `hProcess` is a live process handle returned because
973        // `SEE_MASK_NOCLOSEPROCESS` was set; it is closed exactly once below.
974        let waited = unsafe { WaitForSingleObject(info.hProcess, ELEVATED_WAIT_MS) };
975        let mut code = 1u32;
976        let outcome = if waited == WAIT_OBJECT_0 {
977            // SAFETY: as above; `code` is a live out-parameter.
978            match unsafe { GetExitCodeProcess(info.hProcess, &raw mut code) } {
979                #[allow(clippy::cast_possible_wrap)]
980                Ok(()) => ElevationOutcome::Exited(code as i32),
981                Err(error) => ElevationOutcome::Unavailable(format!(
982                    "the elevated process's exit code could not be read: {}",
983                    error.message()
984                )),
985            }
986        } else {
987            ElevationOutcome::Unavailable(
988                "the elevated process did not finish within 15 minutes".into(),
989            )
990        };
991        // SAFETY: closed exactly once.
992        let _ = unsafe { CloseHandle(info.hProcess) };
993        outcome
994    }
995}
996
997// ---------------------------------------------------------------------------
998// macOS and Linux
999// ---------------------------------------------------------------------------
1000
1001#[cfg(unix)]
1002mod sys {
1003    use std::ffi::OsString;
1004    use std::path::Path;
1005    use std::process::Command;
1006
1007    /// No session to ask about: the terminal decides.
1008    pub(super) const fn session_id() -> Option<u32> {
1009        None
1010    }
1011
1012    use super::{ElevationOutcome, RegistryError};
1013
1014    pub(super) fn is_elevated() -> bool {
1015        // SAFETY: `geteuid` takes no argument and cannot fail.
1016        unsafe { libc::geteuid() == 0 }
1017    }
1018
1019    #[cfg(target_os = "macos")]
1020    pub(super) fn physical_memory_bytes() -> Option<u64> {
1021        let mut value = 0u64;
1022        let mut size = std::mem::size_of::<u64>();
1023        let name = c"hw.memsize";
1024        // SAFETY: `name` is NUL-terminated; `value`/`size` describe a live
1025        // 8-byte buffer owned by this frame; no new value is written.
1026        let result = unsafe {
1027            libc::sysctlbyname(
1028                name.as_ptr(),
1029                (&raw mut value).cast(),
1030                &raw mut size,
1031                std::ptr::null_mut(),
1032                0,
1033            )
1034        };
1035        (result == 0).then_some(value)
1036    }
1037
1038    #[cfg(not(target_os = "macos"))]
1039    pub(super) fn physical_memory_bytes() -> Option<u64> {
1040        let text = std::fs::read_to_string("/proc/meminfo").ok()?;
1041        let line = text.lines().find(|line| line.starts_with("MemTotal:"))?;
1042        let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
1043        kib.checked_mul(1024)
1044    }
1045
1046    fn unsupported() -> RegistryError {
1047        RegistryError::Other("the registry exists only on Windows".into())
1048    }
1049
1050    pub(super) fn read_dword(_: &str, _: &str) -> Result<Option<u32>, RegistryError> {
1051        Err(unsupported())
1052    }
1053
1054    pub(super) fn read_string(_: &str, _: &str) -> Result<Option<String>, RegistryError> {
1055        Err(unsupported())
1056    }
1057
1058    pub(super) fn value_names(_: &str) -> Result<Vec<String>, RegistryError> {
1059        Err(unsupported())
1060    }
1061
1062    pub(super) fn write_dword(_: &str, _: &str, _: Option<u32>) -> Result<(), RegistryError> {
1063        Err(unsupported())
1064    }
1065
1066    pub(super) fn write_string(_: &str, _: &str, _: Option<&str>) -> Result<(), RegistryError> {
1067        Err(unsupported())
1068    }
1069
1070    fn exit_outcome(
1071        status: std::io::Result<std::process::ExitStatus>,
1072        what: &str,
1073    ) -> ElevationOutcome {
1074        match status {
1075            Ok(status) => ElevationOutcome::Exited(status.code().unwrap_or(1)),
1076            Err(error) => {
1077                ElevationOutcome::Unavailable(format!("{what} could not be started: {error}"))
1078            }
1079        }
1080    }
1081
1082    pub(super) fn run_elevated(
1083        program: &Path,
1084        args: &[OsString],
1085        terminal: bool,
1086    ) -> ElevationOutcome {
1087        if terminal
1088            && super::find_on_path("sudo", &std::env::var_os("PATH").unwrap_or_default()).is_some()
1089        {
1090            let status = Command::new("sudo")
1091                .arg("--")
1092                .arg(program)
1093                .args(args)
1094                .status();
1095            // `sudo` exits 1 both for a refused password and for a child that
1096            // exited 1; the child never exits 1 (it reports through its result
1097            // file and exits 0 or 2), so 1 is read as a refusal.
1098            return match exit_outcome(status, "sudo") {
1099                ElevationOutcome::Exited(1) => ElevationOutcome::Refused,
1100                other => other,
1101            };
1102        }
1103        if cfg!(target_os = "macos") {
1104            let command = std::iter::once(program.as_os_str().to_owned())
1105                .chain(args.iter().cloned())
1106                .map(|part| super::quote_posix_argument(&part.to_string_lossy()))
1107                .collect::<Vec<_>>()
1108                .join(" ");
1109            let output = Command::new("/usr/bin/osascript")
1110                .arg("-e")
1111                .arg(super::administrator_applescript(&command))
1112                .output();
1113            return match output {
1114                Ok(output) if output.status.success() => ElevationOutcome::Exited(0),
1115                // -128 is "User canceled." in every locale.
1116                Ok(output) if String::from_utf8_lossy(&output.stderr).contains("(-128)") => {
1117                    ElevationOutcome::Refused
1118                }
1119                Ok(output) => {
1120                    let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1121                    if detail.contains("(-60007)") || detail.contains("(-1743)") {
1122                        ElevationOutcome::Unavailable(format!(
1123                            "macOS could not show the administrator dialog here: {detail}"
1124                        ))
1125                    } else {
1126                        ElevationOutcome::Exited(output.status.code().unwrap_or(2))
1127                    }
1128                }
1129                Err(error) => ElevationOutcome::Unavailable(format!(
1130                    "osascript could not be started: {error}"
1131                )),
1132            };
1133        }
1134        ElevationOutcome::Unavailable(
1135            "no terminal to ask for a sudo password; run the command from a terminal".into(),
1136        )
1137    }
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142    use super::*;
1143
1144    #[test]
1145    fn windows_arguments_survive_the_command_line_round_trip() {
1146        for (argument, expected) in [
1147            ("plain", "plain"),
1148            ("", "\"\""),
1149            ("with space", "\"with space\""),
1150            ("C:\\Program Files\\x\\", "\"C:\\Program Files\\x\\\\\""),
1151            ("say \"hi\"", "\"say \\\"hi\\\"\""),
1152            ("a\\\"b", "\"a\\\\\\\"b\""),
1153            ("{\"k\":\"C:\\\\r m\"}", "\"{\\\"k\\\":\\\"C:\\\\r m\\\"}\""),
1154        ] {
1155            assert_eq!(
1156                crate::service::quote_argument(argument),
1157                expected,
1158                "{argument}"
1159            );
1160        }
1161    }
1162
1163    #[test]
1164    fn posix_and_applescript_quoting_keep_hostile_characters_inert() {
1165        assert_eq!(quote_posix_argument("it's"), "'it'\\''s'");
1166        let script = administrator_applescript("'/a b/rm' 'x\"y' '\\'");
1167        assert_eq!(
1168            script,
1169            "do shell script \"'/a b/rm' 'x\\\"y' '\\\\'\" with administrator privileges"
1170        );
1171    }
1172
1173    #[test]
1174    fn a_symlink_probe_leaves_nothing_behind() {
1175        let directory = tempfile::tempdir().unwrap();
1176        let _ = can_create_symlink(directory.path()).unwrap();
1177        assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
1178    }
1179
1180    #[cfg(unix)]
1181    #[test]
1182    fn unix_accounts_can_always_create_symlinks() {
1183        let directory = tempfile::tempdir().unwrap();
1184        assert!(can_create_symlink(directory.path()).unwrap());
1185    }
1186
1187    #[test]
1188    fn a_tool_is_found_on_path_and_a_path_like_name_never_is() {
1189        let directory = tempfile::tempdir().unwrap();
1190        let name = if cfg!(windows) {
1191            "rmtool.exe"
1192        } else {
1193            "rmtool"
1194        };
1195        let tool = directory.path().join(name);
1196        std::fs::write(&tool, b"").unwrap();
1197        #[cfg(unix)]
1198        {
1199            use std::os::unix::fs::PermissionsExt as _;
1200            std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
1201        }
1202        let path = std::env::join_paths([directory.path()]).unwrap();
1203        assert_eq!(find_on_path("rmtool", &path), Some(tool));
1204        assert_eq!(find_on_path("absent-tool", &path), None);
1205        assert_eq!(find_on_path("../rmtool", &path), None);
1206    }
1207
1208    /// Node.js on Windows ships a POSIX `npm` script beside `npm.cmd`; only the
1209    /// one Windows can start is an answer.
1210    #[cfg(windows)]
1211    #[test]
1212    fn an_extensionless_file_is_never_the_windows_answer() {
1213        let directory = tempfile::tempdir().unwrap();
1214        std::fs::write(directory.path().join("npm"), b"#!/bin/sh\n").unwrap();
1215        let path = std::env::join_paths([directory.path()]).unwrap();
1216        assert_eq!(find_on_path("npm", &path), None);
1217        let cmd = directory.path().join("npm.cmd");
1218        std::fs::write(&cmd, b"@echo off\r\n").unwrap();
1219        assert_eq!(find_on_path("npm", &path), Some(cmd.clone()));
1220        assert_eq!(find_on_path("npm.cmd", &path), Some(cmd));
1221    }
1222
1223    /// A `PATH` value naming `directories`, in order.
1224    fn path_of(directories: &[&Path]) -> OsString {
1225        std::env::join_paths(directories).expect("joinable directories")
1226    }
1227
1228    fn touch(path: &Path) {
1229        std::fs::write(path, b"").expect("a fixture file");
1230    }
1231
1232    /// The machine `update` failed on: `C:\Program Files\nodejs\` holds
1233    /// `npm.cmd` and an extensionless shell script named `npm`, and no
1234    /// `npm.exe` exists, which `Command::new("npm")` alone cannot run.
1235    #[test]
1236    fn npm_shipped_only_as_npm_cmd_is_found_on_a_windows_path() {
1237        let empty = tempfile::tempdir().unwrap();
1238        let nodejs = tempfile::tempdir().unwrap();
1239        touch(&nodejs.path().join("npm"));
1240        touch(&nodejs.path().join("npm.cmd"));
1241        let path = path_of(&[empty.path(), nodejs.path()]);
1242        let cmd = Some(nodejs.path().join("npm.cmd"));
1243
1244        assert_eq!(
1245            find_on_windows_path("npm", &path, Some(".COM;.EXE;.BAT;.CMD".as_ref())),
1246            cmd
1247        );
1248        // An unset or blank PATHEXT means cmd.exe's own default.
1249        assert_eq!(find_on_windows_path("npm", &path, None), cmd);
1250        assert_eq!(find_on_windows_path("npm", &path, Some(" ".as_ref())), cmd);
1251        // A name with its extension is tried as it is, but only a runnable one.
1252        assert_eq!(find_on_windows_path("npm.cmd", &path, None), cmd);
1253        touch(&nodejs.path().join("npm.ps1"));
1254        assert_eq!(find_on_windows_path("npm.ps1", &path, None), None);
1255    }
1256
1257    /// `PATHEXT` order decides within one directory; `PATH` order decides
1258    /// across directories, as it does for `cmd.exe`.
1259    #[test]
1260    fn pathext_orders_extensions_within_a_directory_and_path_orders_directories() {
1261        let both = tempfile::tempdir().unwrap();
1262        touch(&both.path().join("npm.cmd"));
1263        touch(&both.path().join("npm.exe"));
1264        let path = path_of(&[both.path()]);
1265        assert_eq!(
1266            find_on_windows_path("npm", &path, Some(".CMD;.EXE".as_ref())),
1267            Some(both.path().join("npm.cmd"))
1268        );
1269        assert_eq!(
1270            find_on_windows_path("npm", &path, Some(".exe;.cmd".as_ref())),
1271            Some(both.path().join("npm.exe"))
1272        );
1273
1274        let first = tempfile::tempdir().unwrap();
1275        let second = tempfile::tempdir().unwrap();
1276        touch(&first.path().join("npm.cmd"));
1277        touch(&second.path().join("npm.exe"));
1278        assert_eq!(
1279            find_on_windows_path(
1280                "npm",
1281                &path_of(&[first.path(), second.path()]),
1282                Some(".EXE;.CMD".as_ref())
1283            ),
1284            Some(first.path().join("npm.cmd")),
1285            "an earlier PATH directory wins over a preferred extension later on"
1286        );
1287    }
1288
1289    /// No npm is `None`. An extension `Command` cannot run, and a relative
1290    /// PATH entry, are not candidates.
1291    #[test]
1292    #[serial_test::serial(current_dir)]
1293    fn a_missing_npm_is_not_found_on_a_windows_path() {
1294        let empty = tempfile::tempdir().unwrap();
1295        assert_eq!(
1296            find_on_windows_path("npm", &path_of(&[empty.path()]), None),
1297            None
1298        );
1299
1300        let script_only = tempfile::tempdir().unwrap();
1301        touch(&script_only.path().join("npm.ps1"));
1302        assert_eq!(
1303            find_on_windows_path(
1304                "npm",
1305                &path_of(&[script_only.path()]),
1306                Some(".PS1;.EXE;.CMD".as_ref())
1307            ),
1308            None
1309        );
1310
1311        // A relative entry that really does lead to an `npm.cmd` from here,
1312        // so only the rule can be what refuses it.
1313        let planted = tempfile::tempdir().unwrap();
1314        touch(&planted.path().join("npm.cmd"));
1315        if let Some(relative) = relative_to_current_directory(planted.path()) {
1316            assert!(relative.join("npm.cmd").is_file(), "{}", relative.display());
1317            assert_eq!(
1318                find_on_windows_path("npm", &path_of(&[&relative]), None),
1319                None
1320            );
1321        }
1322    }
1323
1324    /// `target` as a path relative to the current directory, or `None` when
1325    /// the two share no root (another Windows drive).
1326    fn relative_to_current_directory(target: &Path) -> Option<PathBuf> {
1327        let here = std::env::current_dir().ok()?;
1328        let target = std::fs::canonicalize(target).ok()?;
1329        let here = std::fs::canonicalize(here).ok()?;
1330        let (here, target): (Vec<_>, Vec<_>) =
1331            (here.components().collect(), target.components().collect());
1332        let common = here
1333            .iter()
1334            .zip(&target)
1335            .take_while(|(left, right)| left == right)
1336            .count();
1337        if common == 0 {
1338            return None;
1339        }
1340        let mut relative: PathBuf = std::iter::repeat_n("..", here.len() - common).collect();
1341        relative.extend(&target[common..]);
1342        Some(relative)
1343    }
1344
1345    fn unfit_finding(id: &str) -> UnfitFinding {
1346        UnfitFinding {
1347            id: id.into(),
1348            detail: "it fails".into(),
1349            remedy: None,
1350        }
1351    }
1352
1353    /// A UAC prompt is a desktop dialog: a session with a desktop can answer it
1354    /// with no terminal at all, and session 0 cannot even with one.
1355    #[test]
1356    fn who_can_answer_an_administrator_prompt() {
1357        assert!(
1358            prompt_answerable(Some(1), false),
1359            "the desktop, no terminal"
1360        );
1361        assert!(prompt_answerable(Some(2), true));
1362        assert!(!prompt_answerable(Some(0), true), "services and SSH");
1363        assert!(prompt_answerable(None, true), "sudo on a terminal");
1364        assert!(!prompt_answerable(None, false));
1365    }
1366
1367    /// A stalled daemon cannot re-stamp its refusal, and the refusal stands.
1368    #[test]
1369    fn an_unfit_record_stays_in_force_while_its_daemon_is_stalled() {
1370        let root = tempfile::tempdir().unwrap();
1371        let paths = AppPaths::rooted_at(root.path());
1372        paths.create_all().unwrap();
1373        let then = Utc::now();
1374        record_host_unfit(
1375            &paths,
1376            &[unfit_finding("host.runner_root_responsive")],
1377            then,
1378        )
1379        .unwrap();
1380        let later = then + chrono::Duration::hours(1);
1381        assert_eq!(
1382            host_unfit_in_force(&paths, later),
1383            None,
1384            "nobody beats: stopped"
1385        );
1386
1387        // The same process, still running, last heard from with the record.
1388        crate::daemon_heartbeat::beat(&paths, then).unwrap();
1389        assert!(host_unfit_in_force(&paths, later).is_some(), "stalled");
1390        crate::daemon_heartbeat::beat(&paths, later).unwrap();
1391        assert_eq!(
1392            host_unfit_in_force(&paths, later),
1393            None,
1394            "a daemon still beating would have re-stamped a refusal in force"
1395        );
1396
1397        // A record from long before the stalled daemon's last beat is another
1398        // generation's, and does not come back.
1399        let much_later = later + chrono::Duration::hours(1);
1400        assert_eq!(host_unfit_in_force(&paths, much_later), None);
1401    }
1402
1403    #[test]
1404    fn the_unfit_record_keeps_its_first_moment_and_clears() {
1405        let root = tempfile::tempdir().unwrap();
1406        let paths = AppPaths::rooted_at(root.path());
1407        paths.create_all().unwrap();
1408        assert_eq!(host_unfit(&paths).unwrap(), None);
1409        let first = Utc::now() - chrono::Duration::minutes(10);
1410        record_host_unfit(&paths, &[unfit_finding("windows.symlink_privilege")], first).unwrap();
1411        let later = Utc::now();
1412        record_host_unfit(&paths, &[unfit_finding("host.required_tools")], later).unwrap();
1413        let record = host_unfit(&paths).unwrap().unwrap();
1414        assert_eq!(record.since, first);
1415        assert_eq!(record.checked_at, later);
1416        assert_eq!(record.checks, ["host.required_tools"]);
1417        clear_host_unfit(&paths).unwrap();
1418        assert_eq!(host_unfit(&paths).unwrap(), None);
1419        clear_host_unfit(&paths).unwrap();
1420    }
1421
1422    #[test]
1423    fn physical_memory_is_reported() {
1424        assert!(physical_memory_bytes().is_some_and(|bytes| bytes > 256 * 1024 * 1024));
1425    }
1426
1427    #[test]
1428    fn a_directory_responds_and_a_not_yet_created_one_is_judged_by_its_parent() {
1429        let directory = tempfile::tempdir().unwrap();
1430        let deadline = std::time::Duration::from_secs(10);
1431        assert_eq!(
1432            directory_responds(directory.path(), deadline),
1433            Responsiveness::Responds
1434        );
1435        assert_eq!(
1436            directory_responds(&directory.path().join("not-yet").join("deeper"), deadline),
1437            Responsiveness::Responds
1438        );
1439        // Only this test's paths: the registry is shared with tests running
1440        // beside it.
1441        assert!(
1442            !PROBES_IN_FLIGHT
1443                .lock()
1444                .unwrap()
1445                .iter()
1446                .any(|(path, _)| path.starts_with(directory.path())),
1447            "a finished probe deregisters"
1448        );
1449    }
1450
1451    /// While an earlier probe of a path is still blocked, a new one answers at
1452    /// once instead of parking another thread on the same volume, and says
1453    /// what the stuck one is blocked on: a daemon re-checking every five
1454    /// minutes behind a pending privacy question keeps naming it.
1455    #[test]
1456    fn a_path_still_being_probed_answers_without_a_second_thread() {
1457        let directory = tempfile::tempdir().unwrap();
1458        for (name, listing, expected) in [
1459            ("stuck-volume", false, Responsiveness::Hung),
1460            ("asking-volume", true, Responsiveness::ListingBlocked),
1461        ] {
1462            let stuck = directory.path().join(name);
1463            PROBES_IN_FLIGHT.lock().unwrap().push((
1464                stuck.clone(),
1465                std::sync::Arc::new(std::sync::atomic::AtomicBool::new(listing)),
1466            ));
1467            let started = std::time::Instant::now();
1468            let answer = directory_responds(&stuck, std::time::Duration::from_secs(30));
1469            PROBES_IN_FLIGHT
1470                .lock()
1471                .unwrap()
1472                .retain(|(path, _)| *path != stuck);
1473            assert_eq!(answer, expected, "{name}");
1474            assert!(started.elapsed() < std::time::Duration::from_secs(5));
1475        }
1476    }
1477}