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