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