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