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