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