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/// The Windows system directory, from the kernel rather than from the
112/// writable `%SystemRoot%`: an elevated fix starts `powershell.exe` from it.
113///
114/// # Errors
115/// The system's error when the directory cannot be reported.
116#[cfg(windows)]
117pub fn system_directory() -> std::io::Result<PathBuf> {
118    crate::runner_root::windows_system_directory().map(PathBuf::from)
119}
120
121/// Replaces `path` with `bytes` atomically: a temporary file beside it, flushed
122/// to disk, then renamed over it, so a reader never sees half a file.
123///
124/// # Errors
125/// The I/O error of any step.
126pub fn write_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
127    use std::io::Write as _;
128    let parent = path.parent().unwrap_or_else(|| Path::new("."));
129    std::fs::create_dir_all(parent)?;
130    let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
131    temporary.write_all(bytes)?;
132    temporary.as_file().sync_all()?;
133    temporary
134        .persist(path)
135        .map(|_| ())
136        .map_err(|error| error.error)
137}
138
139/// How a bounded look at a directory went.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum Responsiveness {
142    /// It answered a `stat` and the first entry of a listing in time.
143    Responds,
144    /// It did not answer within the deadline: a hung volume (a stalled USB or
145    /// NVMe enclosure, a dead network mount).
146    Hung,
147    /// Its metadata answered, and listing it did not within the deadline. On
148    /// macOS that is what a pending privacy question looks like: reading a
149    /// folder on a removable or network volume waits while "would like to
150    /// access files on a removable volume" is on the desktop, while `stat`
151    /// does not. A stalled disk usually stalls the `stat` too.
152    ListingBlocked,
153    /// The system refused to let this process list it (`EPERM`). On macOS
154    /// that is a privacy setting this process was refused, not file
155    /// permissions, which answer `EACCES`.
156    NotPermitted(String),
157    /// It answered with an error.
158    Failed(String),
159}
160
161/// A [`directory_responds`] probe still blocked: its path, and whether its
162/// metadata had answered (so the listing is what blocks).
163type InFlight = (PathBuf, std::sync::Arc<std::sync::atomic::AtomicBool>);
164
165/// Probes still blocked, so a volume that stays hung costs one stuck thread
166/// rather than one per probe, and a later probe of it reports what the stuck
167/// one is blocked on.
168static PROBES_IN_FLIGHT: std::sync::Mutex<Vec<InFlight>> = std::sync::Mutex::new(Vec::new());
169
170/// The directories a [`directory_responds`] probe is still blocked on.
171#[must_use]
172pub fn probes_in_flight() -> Vec<PathBuf> {
173    PROBES_IN_FLIGHT
174        .lock()
175        .map(|in_flight| in_flight.iter().map(|(path, _)| path.clone()).collect())
176        .unwrap_or_default()
177}
178
179/// How a probe that has not answered is reported.
180fn unanswered(listing: &std::sync::atomic::AtomicBool) -> Responsiveness {
181    if listing.load(std::sync::atomic::Ordering::Acquire) {
182        Responsiveness::ListingBlocked
183    } else {
184        Responsiveness::Hung
185    }
186}
187
188/// Whether `directory` (or, when it does not exist yet, its nearest existing
189/// ancestor) answers a `stat` and a one-entry listing within `deadline`.
190///
191/// The look runs on its own thread because a call into a hung filesystem does
192/// not return and cannot be cancelled. That thread is left blocked when the
193/// deadline passes, and while it is, later probes of the same path report
194/// [`Responsiveness::Hung`] at once instead of stacking more threads on it.
195#[must_use]
196pub fn directory_responds(directory: &Path, deadline: std::time::Duration) -> Responsiveness {
197    let path = directory.to_path_buf();
198    // Set once the metadata has answered, so a probe still blocked can say
199    // whether it was the `stat` or the listing that never came back.
200    let listing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
201    {
202        let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() else {
203            return Responsiveness::Failed("the probe registry is poisoned".into());
204        };
205        if let Some((_, stuck)) = in_flight.iter().find(|(stuck, _)| *stuck == path) {
206            return unanswered(stuck);
207        }
208        in_flight.push((path.clone(), std::sync::Arc::clone(&listing)));
209    }
210    let (sender, receiver) = std::sync::mpsc::channel();
211    let probed = path.clone();
212    let listing_started = std::sync::Arc::clone(&listing);
213    let spawned = std::thread::Builder::new()
214        .name("runner-root-probe".into())
215        .spawn(move || {
216            let existing = probed
217                .ancestors()
218                .find(|ancestor| std::fs::symlink_metadata(ancestor).is_ok())
219                .map(Path::to_path_buf);
220            listing_started.store(true, std::sync::atomic::Ordering::Release);
221            let refused = |error: std::io::Error| {
222                if is_not_permitted(&error) {
223                    Responsiveness::NotPermitted(error.to_string())
224                } else {
225                    Responsiveness::Failed(error.to_string())
226                }
227            };
228            let answer = match existing {
229                None => Responsiveness::Failed(format!("{} does not exist", probed.display())),
230                Some(existing) => match std::fs::read_dir(&existing) {
231                    Ok(mut entries) => match entries.next() {
232                        Some(Err(error)) => refused(error),
233                        _ => Responsiveness::Responds,
234                    },
235                    Err(error) => refused(error),
236                },
237            };
238            if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
239                in_flight.retain(|(candidate, _)| *candidate != probed);
240            }
241            let _ = sender.send(answer);
242        });
243    if let Err(error) = spawned {
244        if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
245            in_flight.retain(|(candidate, _)| *candidate != path);
246        }
247        return Responsiveness::Failed(error.to_string());
248    }
249    receiver
250        .recv_timeout(deadline)
251        .unwrap_or_else(|_| unanswered(&listing))
252}
253
254/// `EPERM`, which a privacy refusal answers, as opposed to `EACCES`.
255fn is_not_permitted(error: &std::io::Error) -> bool {
256    #[cfg(unix)]
257    {
258        error.raw_os_error() == Some(libc::EPERM)
259    }
260    #[cfg(not(unix))]
261    {
262        let _ = error;
263        false
264    }
265}
266
267/// Whether this process can create a symbolic link inside `directory`.
268///
269/// Measured rather than inferred: a link is created and removed again. On
270/// Windows this answers for the account and token this process runs with,
271/// which for the daemon is exactly the account runners inherit; a file link
272/// is what `npm`, `pnpm` and `setup-bun` create, and it needs either
273/// `SeCreateSymbolicLinkPrivilege` or Developer Mode.
274///
275/// # Errors
276/// The I/O error when the probe's target file cannot even be created, which
277/// says nothing about links.
278pub fn can_create_symlink(directory: &Path) -> std::io::Result<bool> {
279    std::fs::create_dir_all(directory)?;
280    let unique = format!(
281        ".rm-symlink-probe-{}-{}",
282        std::process::id(),
283        uuid::Uuid::new_v4().simple()
284    );
285    let target = directory.join(format!("{unique}.target"));
286    let link = directory.join(format!("{unique}.link"));
287    std::fs::write(&target, b"")?;
288    let created = symlink_file(&target, &link);
289    let _ = std::fs::remove_file(&link);
290    let _ = std::fs::remove_file(&target);
291    Ok(created.is_ok())
292}
293
294#[cfg(windows)]
295fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
296    std::os::windows::fs::symlink_file(target, link)
297}
298
299#[cfg(unix)]
300fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
301    std::os::unix::fs::symlink(target, link)
302}
303
304/// Finds `tool` on `path` the way a shell would, including `PATHEXT` on
305/// Windows. A name holding a path separator is never searched.
306#[must_use]
307pub fn find_on_path(tool: &str, path: &OsStr) -> Option<PathBuf> {
308    if tool.is_empty() || tool.contains(['/', '\\']) {
309        return None;
310    }
311    let extensions: Vec<String> = if cfg!(windows) {
312        let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
313        // A bare name is tried as-is only when it already carries an
314        // extension: `cmd` and `CreateProcess` never run an extensionless file,
315        // and Node.js ships a POSIX `npm` script beside `npm.cmd`.
316        let mut list = Vec::new();
317        if tool.contains('.') {
318            list.push(String::new());
319        }
320        list.extend(
321            pathext
322                .split(';')
323                .filter(|ext| !ext.is_empty())
324                .map(str::to_ascii_lowercase),
325        );
326        list
327    } else {
328        vec![String::new()]
329    };
330    std::env::split_paths(path)
331        .filter(|dir| !dir.as_os_str().is_empty())
332        .find_map(|dir| {
333            extensions.iter().find_map(|extension| {
334                let candidate = dir.join(format!("{tool}{extension}"));
335                is_executable(&candidate).then_some(candidate)
336            })
337        })
338}
339
340#[cfg(unix)]
341fn is_executable(path: &Path) -> bool {
342    use std::os::unix::fs::PermissionsExt as _;
343    std::fs::metadata(path)
344        .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
345}
346
347#[cfg(windows)]
348fn is_executable(path: &Path) -> bool {
349    std::fs::metadata(path).is_ok_and(|meta| meta.is_file())
350}
351
352// ---------------------------------------------------------------------------
353// The Windows registry (HKEY_LOCAL_MACHINE only)
354// ---------------------------------------------------------------------------
355
356/// Why a registry read or write failed.
357#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
358pub enum RegistryError {
359    /// The key exists and this token may not read (or write) it. Defender's
360    /// exclusion list answers this to every non-administrator.
361    #[error("access denied")]
362    AccessDenied,
363    /// Any other failure, with the system's code.
364    #[error("{0}")]
365    Other(String),
366}
367
368/// Reads a `REG_DWORD` under `HKLM\subkey`; `None` when the key or value is
369/// absent.
370///
371/// # Errors
372/// [`RegistryError`] when it exists and cannot be read.
373pub fn read_registry_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
374    sys::read_dword(subkey, value)
375}
376
377/// Reads a `REG_SZ` or `REG_EXPAND_SZ` (unexpanded) under `HKLM\subkey`.
378///
379/// # Errors
380/// [`RegistryError`] when it exists and cannot be read.
381pub fn read_registry_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
382    sys::read_string(subkey, value)
383}
384
385/// The names of every value under `HKLM\subkey`; an empty list when the key
386/// does not exist.
387///
388/// # Errors
389/// [`RegistryError::AccessDenied`] when the key may not be read.
390pub fn registry_value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
391    sys::value_names(subkey)
392}
393
394/// Writes (`Some`) or deletes (`None`) a `REG_DWORD` under `HKLM\subkey`,
395/// creating the key when needed. Needs administrator rights.
396///
397/// # Errors
398/// [`RegistryError`] when the write is refused.
399pub fn write_registry_dword(
400    subkey: &str,
401    value: &str,
402    data: Option<u32>,
403) -> Result<(), RegistryError> {
404    sys::write_dword(subkey, value, data)
405}
406
407/// Writes (`Some`) or deletes (`None`) a `REG_SZ` under `HKLM\subkey`. Needs
408/// administrator rights.
409///
410/// # Errors
411/// [`RegistryError`] when the write is refused.
412pub fn write_registry_string(
413    subkey: &str,
414    value: &str,
415    data: Option<&str>,
416) -> Result<(), RegistryError> {
417    sys::write_string(subkey, value, data)
418}
419
420// ---------------------------------------------------------------------------
421// The daemon's host-unfit record
422// ---------------------------------------------------------------------------
423
424/// The file under `state/` the daemon keeps while it refuses to start runners.
425pub const HOST_UNFIT_FILE: &str = "host-unfit.json";
426
427/// How often the daemon re-evaluates the required checks, re-stamping the
428/// record while they fail.
429pub const DAEMON_RECHECK: std::time::Duration = std::time::Duration::from_secs(5 * 60);
430
431/// How long the record counts as a refusal in force: three rechecks, so one
432/// slow evaluation does not hide it, and a record nobody re-stamps for longer
433/// was left behind by a daemon that stopped while refusing.
434pub const HOST_UNFIT_RECORD_FRESH: std::time::Duration =
435    std::time::Duration::from_secs(3 * DAEMON_RECHECK.as_secs());
436const HOST_UNFIT_SCHEMA_VERSION: u32 = 1;
437
438/// What the daemon recorded the last time a required host check failed.
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
440pub struct HostUnfitRecord {
441    pub schema_version: u32,
442    /// The first moment of the ongoing refusal.
443    pub since: DateTime<Utc>,
444    /// When the checks were last evaluated.
445    pub checked_at: DateTime<Utc>,
446    /// The ids of the required checks that failed.
447    pub checks: Vec<String>,
448    /// What each of them found, from the daemon's own session, and what fixes
449    /// it. Empty in a record an older daemon wrote.
450    #[serde(default)]
451    pub findings: Vec<UnfitFinding>,
452}
453
454/// One failing required check, as the daemon saw it.
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456pub struct UnfitFinding {
457    pub id: String,
458    pub detail: String,
459    #[serde(default)]
460    pub remedy: Option<String>,
461}
462
463impl HostUnfitRecord {
464    /// One sentence per failing check, with its remedy.
465    #[must_use]
466    pub fn describe(&self) -> String {
467        if self.findings.is_empty() {
468            return self.checks.join(", ");
469        }
470        self.findings
471            .iter()
472            .map(|finding| match &finding.remedy {
473                Some(remedy) => format!("{}: {}. Fix: {remedy}", finding.id, finding.detail),
474                None => format!("{}: {}", finding.id, finding.detail),
475            })
476            .collect::<Vec<_>>()
477            .join("; ")
478    }
479}
480
481fn host_unfit_path(paths: &AppPaths) -> PathBuf {
482    paths.state_dir().join(HOST_UNFIT_FILE)
483}
484
485/// Records that required checks failed, keeping the first moment of an
486/// ongoing refusal.
487///
488/// # Errors
489/// A description of the write failure.
490pub fn record_host_unfit(
491    paths: &AppPaths,
492    findings: &[UnfitFinding],
493    at: DateTime<Utc>,
494) -> Result<(), String> {
495    let since = host_unfit(paths)
496        .ok()
497        .flatten()
498        .map_or(at, |record| record.since);
499    let record = HostUnfitRecord {
500        schema_version: HOST_UNFIT_SCHEMA_VERSION,
501        since,
502        checked_at: at,
503        checks: findings.iter().map(|finding| finding.id.clone()).collect(),
504        findings: findings.to_vec(),
505    };
506    let path = host_unfit_path(paths);
507    let text = serde_json::to_vec_pretty(&record).map_err(|error| error.to_string())?;
508    write_atomically(&path, &text)
509        .map_err(|error| format!("cannot write {}: {error}", path.display()))
510}
511
512/// Removes the record once every required check passes.
513///
514/// # Errors
515/// A description of the failure when the record exists and cannot be removed.
516pub fn clear_host_unfit(paths: &AppPaths) -> Result<(), String> {
517    let path = host_unfit_path(paths);
518    match std::fs::remove_file(&path) {
519        Ok(()) => Ok(()),
520        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
521        Err(error) => Err(format!("cannot remove {}: {error}", path.display())),
522    }
523}
524
525/// The daemon's refusal, when one is in force at `now`: recorded, and either
526/// re-stamped within [`HOST_UNFIT_RECORD_FRESH`] or left by a daemon that is
527/// still running and has stalled.
528///
529/// A record nobody re-stamps is a stopped daemon's, not a refusal in force.
530/// But a daemon that stalls stops re-stamping too, and its cause usually
531/// persists: the refusal used to vanish from `status` after fifteen minutes on
532/// a daemon still blocked behind the very question that made the host unfit.
533#[must_use]
534pub fn host_unfit_in_force(paths: &AppPaths, now: DateTime<Utc>) -> Option<HostUnfitRecord> {
535    host_unfit(paths).ok().flatten().filter(|record| {
536        if !crate::wsl::fence::elapsed_at_least(record.checked_at, now, HOST_UNFIT_RECORD_FRESH) {
537            return true;
538        }
539        // Stamped by the daemon that stalled, near its last beat: an older
540        // record is another generation's, and says nothing about this one.
541        match crate::daemon_heartbeat::liveness(paths, now) {
542            crate::daemon_heartbeat::Liveness::Stalled { since, .. } => {
543                !crate::wsl::fence::elapsed_at_least(
544                    record.checked_at,
545                    since,
546                    HOST_UNFIT_RECORD_FRESH,
547                )
548            }
549            _ => false,
550        }
551    })
552}
553
554/// The daemon's current refusal, if it has one.
555///
556/// # Errors
557/// A description of the failure when the file exists and cannot be read.
558pub fn host_unfit(paths: &AppPaths) -> Result<Option<HostUnfitRecord>, String> {
559    let path = host_unfit_path(paths);
560    match std::fs::read_to_string(&path) {
561        Ok(text) => serde_json::from_str(&text)
562            .map(Some)
563            .map_err(|error| format!("cannot parse {}: {error}", path.display())),
564        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
565        Err(error) => Err(format!("cannot read {}: {error}", path.display())),
566    }
567}
568
569// ---------------------------------------------------------------------------
570// Windows
571// ---------------------------------------------------------------------------
572
573#[cfg(windows)]
574mod sys {
575    use std::ffi::OsString;
576    use std::os::windows::ffi::OsStrExt as _;
577    use std::path::Path;
578
579    use windows::Win32::Foundation::{
580        CloseHandle, ERROR_ACCESS_DENIED, ERROR_CANCELLED, ERROR_FILE_NOT_FOUND,
581        ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, HANDLE, WAIT_OBJECT_0, WIN32_ERROR,
582    };
583    use windows::Win32::Security::{
584        GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation, TokenSessionId,
585    };
586    use windows::Win32::System::Registry::{
587        HKEY, HKEY_LOCAL_MACHINE, KEY_READ, KEY_SET_VALUE, REG_DWORD, REG_OPTION_NON_VOLATILE,
588        REG_SZ, RRF_NOEXPAND, RRF_RT_REG_DWORD, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey,
589        RegCreateKeyExW, RegDeleteKeyValueW, RegEnumValueW, RegGetValueW, RegOpenKeyExW,
590        RegSetValueExW,
591    };
592    use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
593    use windows::Win32::System::Threading::{
594        GetCurrentProcess, GetExitCodeProcess, OpenProcessToken, WaitForSingleObject,
595    };
596    use windows::Win32::UI::Shell::{
597        SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW,
598    };
599    use windows::core::{PCWSTR, PWSTR};
600
601    use super::{ElevationOutcome, RegistryError};
602
603    /// `SW_HIDE`. Spelled here rather than pulling in the whole
604    /// `WindowsAndMessaging` module for one integer.
605    const SW_HIDE: i32 = 0;
606    /// The longest an elevated fix batch is waited for. Generous: Defender
607    /// and `git config` answer in seconds, and a person may take a while to
608    /// read the UAC prompt.
609    const ELEVATED_WAIT_MS: u32 = 15 * 60 * 1000;
610
611    fn wide(text: &str) -> Vec<u16> {
612        text.encode_utf16().chain(std::iter::once(0)).collect()
613    }
614
615    fn registry_error(code: WIN32_ERROR) -> RegistryError {
616        if code == ERROR_ACCESS_DENIED {
617            RegistryError::AccessDenied
618        } else {
619            RegistryError::Other(format!("registry error {}", code.0))
620        }
621    }
622
623    /// The Terminal Services session this process runs in.
624    pub(super) fn session_id() -> Option<u32> {
625        let mut token = HANDLE::default();
626        // SAFETY: the pseudo-handle from `GetCurrentProcess` needs no closing;
627        // `token` is a live out-parameter owned by this frame.
628        if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
629            return None;
630        }
631        let mut session = 0u32;
632        let mut returned = 0u32;
633        // SAFETY: `session` is a `u32` owned by this frame, which is what
634        // `TokenSessionId` returns, and its size is the length passed.
635        let queried = unsafe {
636            GetTokenInformation(
637                token,
638                TokenSessionId,
639                Some((&raw mut session).cast()),
640                4,
641                &raw mut returned,
642            )
643        };
644        // SAFETY: `token` was opened above and is closed exactly once.
645        let _ = unsafe { CloseHandle(token) };
646        queried.is_ok().then_some(session)
647    }
648
649    pub(super) fn is_elevated() -> bool {
650        let mut token = HANDLE::default();
651        // SAFETY: the pseudo-handle from `GetCurrentProcess` needs no closing;
652        // `token` is a live out-parameter owned by this frame.
653        if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
654            return false;
655        }
656        let mut elevation = TOKEN_ELEVATION::default();
657        let mut returned = 0u32;
658        // SAFETY: `elevation` is a correctly sized `TOKEN_ELEVATION` owned by
659        // this frame, and its size is the length passed.
660        let queried = unsafe {
661            GetTokenInformation(
662                token,
663                TokenElevation,
664                Some((&raw mut elevation).cast()),
665                u32::try_from(std::mem::size_of::<TOKEN_ELEVATION>()).unwrap_or(4),
666                &raw mut returned,
667            )
668        };
669        // SAFETY: `token` was opened above and is closed exactly once.
670        let _ = unsafe { CloseHandle(token) };
671        queried.is_ok() && elevation.TokenIsElevated != 0
672    }
673
674    pub(super) fn physical_memory_bytes() -> Option<u64> {
675        let mut status = MEMORYSTATUSEX {
676            dwLength: u32::try_from(std::mem::size_of::<MEMORYSTATUSEX>()).ok()?,
677            ..MEMORYSTATUSEX::default()
678        };
679        // SAFETY: `status` is a correctly sized, length-stamped structure
680        // owned by this frame.
681        unsafe { GlobalMemoryStatusEx(&raw mut status) }.ok()?;
682        Some(status.ullTotalPhys)
683    }
684
685    pub(super) fn read_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
686        let subkey = wide(subkey);
687        let value = wide(value);
688        let mut data = 0u32;
689        let mut size = 4u32;
690        // SAFETY: both names are NUL-terminated and outlive the call; `data`
691        // and `size` are live and describe a 4-byte buffer.
692        let code = unsafe {
693            RegGetValueW(
694                HKEY_LOCAL_MACHINE,
695                PCWSTR(subkey.as_ptr()),
696                PCWSTR(value.as_ptr()),
697                RRF_RT_REG_DWORD,
698                None,
699                Some((&raw mut data).cast()),
700                Some(&raw mut size),
701            )
702        };
703        match code {
704            ERROR_SUCCESS => Ok(Some(data)),
705            ERROR_FILE_NOT_FOUND => Ok(None),
706            other => Err(registry_error(other)),
707        }
708    }
709
710    pub(super) fn read_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
711        let subkey = wide(subkey);
712        let value = wide(value);
713        let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ | RRF_NOEXPAND;
714        let mut size = 0u32;
715        // SAFETY: a size query; both names are NUL-terminated and outlive it.
716        let code = unsafe {
717            RegGetValueW(
718                HKEY_LOCAL_MACHINE,
719                PCWSTR(subkey.as_ptr()),
720                PCWSTR(value.as_ptr()),
721                flags,
722                None,
723                None,
724                Some(&raw mut size),
725            )
726        };
727        match code {
728            ERROR_SUCCESS => {}
729            ERROR_FILE_NOT_FOUND => return Ok(None),
730            other => return Err(registry_error(other)),
731        }
732        let mut buffer = vec![0u16; (size as usize).div_ceil(2) + 1];
733        let mut size = u32::try_from(buffer.len() * 2).unwrap_or(u32::MAX);
734        // SAFETY: `buffer` is owned by this frame and `size` is its length in
735        // bytes.
736        let code = unsafe {
737            RegGetValueW(
738                HKEY_LOCAL_MACHINE,
739                PCWSTR(subkey.as_ptr()),
740                PCWSTR(value.as_ptr()),
741                flags,
742                None,
743                Some(buffer.as_mut_ptr().cast()),
744                Some(&raw mut size),
745            )
746        };
747        match code {
748            ERROR_SUCCESS => {
749                let units = (size as usize / 2).min(buffer.len());
750                let text = String::from_utf16_lossy(&buffer[..units]);
751                Ok(Some(text.trim_end_matches('\0').to_owned()))
752            }
753            ERROR_FILE_NOT_FOUND => Ok(None),
754            other => Err(registry_error(other)),
755        }
756    }
757
758    pub(super) fn value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
759        let subkey = wide(subkey);
760        let mut key = HKEY::default();
761        // SAFETY: `subkey` is NUL-terminated and `key` is a live out-parameter.
762        let code = unsafe {
763            RegOpenKeyExW(
764                HKEY_LOCAL_MACHINE,
765                PCWSTR(subkey.as_ptr()),
766                None,
767                KEY_READ,
768                &raw mut key,
769            )
770        };
771        match code {
772            ERROR_SUCCESS => {}
773            ERROR_FILE_NOT_FOUND => return Ok(Vec::new()),
774            other => return Err(registry_error(other)),
775        }
776        let mut names = Vec::new();
777        let mut index = 0u32;
778        let result = loop {
779            // 16,383 characters is the documented maximum value-name length.
780            let mut name = vec![0u16; 16_384];
781            let mut length = u32::try_from(name.len()).unwrap_or(u32::MAX);
782            // SAFETY: `name` is owned by this frame and `length` is its size
783            // in characters; every other out-parameter is omitted.
784            let code = unsafe {
785                RegEnumValueW(
786                    key,
787                    index,
788                    Some(PWSTR(name.as_mut_ptr())),
789                    &raw mut length,
790                    None,
791                    None,
792                    None,
793                    None,
794                )
795            };
796            match code {
797                ERROR_SUCCESS => {
798                    names.push(String::from_utf16_lossy(&name[..length as usize]));
799                    index += 1;
800                }
801                ERROR_NO_MORE_ITEMS => break Ok(names),
802                other => break Err(registry_error(other)),
803            }
804        };
805        // SAFETY: `key` was opened above and is closed exactly once.
806        let _ = unsafe { RegCloseKey(key) };
807        result
808    }
809
810    fn write_value(
811        subkey: &str,
812        value: &str,
813        data: Option<(windows::Win32::System::Registry::REG_VALUE_TYPE, Vec<u8>)>,
814    ) -> Result<(), RegistryError> {
815        let subkey_wide = wide(subkey);
816        let value_wide = wide(value);
817        let Some((kind, bytes)) = data else {
818            // SAFETY: both names are NUL-terminated and outlive the call.
819            let code = unsafe {
820                RegDeleteKeyValueW(
821                    HKEY_LOCAL_MACHINE,
822                    PCWSTR(subkey_wide.as_ptr()),
823                    PCWSTR(value_wide.as_ptr()),
824                )
825            };
826            return match code {
827                ERROR_SUCCESS | ERROR_FILE_NOT_FOUND => Ok(()),
828                other => Err(registry_error(other)),
829            };
830        };
831        let mut key = HKEY::default();
832        // SAFETY: `subkey_wide` is NUL-terminated; `key` is a live
833        // out-parameter; no class, security attributes or disposition.
834        let code = unsafe {
835            RegCreateKeyExW(
836                HKEY_LOCAL_MACHINE,
837                PCWSTR(subkey_wide.as_ptr()),
838                None,
839                PCWSTR::null(),
840                REG_OPTION_NON_VOLATILE,
841                KEY_SET_VALUE,
842                None,
843                &raw mut key,
844                None,
845            )
846        };
847        if code != ERROR_SUCCESS {
848            return Err(registry_error(code));
849        }
850        // SAFETY: `key` is open for `KEY_SET_VALUE`; the value name is
851        // NUL-terminated and `bytes` outlives the call.
852        let code =
853            unsafe { RegSetValueExW(key, PCWSTR(value_wide.as_ptr()), None, kind, Some(&bytes)) };
854        // SAFETY: `key` was opened above and is closed exactly once.
855        let _ = unsafe { RegCloseKey(key) };
856        if code == ERROR_SUCCESS {
857            Ok(())
858        } else {
859            Err(registry_error(code))
860        }
861    }
862
863    pub(super) fn write_dword(
864        subkey: &str,
865        value: &str,
866        data: Option<u32>,
867    ) -> Result<(), RegistryError> {
868        write_value(
869            subkey,
870            value,
871            data.map(|data| (REG_DWORD, data.to_le_bytes().to_vec())),
872        )
873    }
874
875    pub(super) fn write_string(
876        subkey: &str,
877        value: &str,
878        data: Option<&str>,
879    ) -> Result<(), RegistryError> {
880        write_value(
881            subkey,
882            value,
883            data.map(|text| {
884                let bytes = wide(text)
885                    .iter()
886                    .flat_map(|unit| unit.to_le_bytes())
887                    .collect();
888                (REG_SZ, bytes)
889            }),
890        )
891    }
892
893    pub(super) fn run_elevated(
894        program: &Path,
895        args: &[OsString],
896        _terminal: bool,
897    ) -> ElevationOutcome {
898        let file: Vec<u16> = program
899            .as_os_str()
900            .encode_wide()
901            .chain(std::iter::once(0))
902            .collect();
903        let parameters = args
904            .iter()
905            .map(|argument| crate::service::quote_argument(&argument.to_string_lossy()))
906            .collect::<Vec<_>>()
907            .join(" ");
908        let parameters = wide(&parameters);
909        let verb = wide("runas");
910        let mut info = SHELLEXECUTEINFOW {
911            cbSize: u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).unwrap_or(0),
912            fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC,
913            lpVerb: PCWSTR(verb.as_ptr()),
914            lpFile: PCWSTR(file.as_ptr()),
915            lpParameters: PCWSTR(parameters.as_ptr()),
916            nShow: SW_HIDE,
917            ..SHELLEXECUTEINFOW::default()
918        };
919        // SAFETY: every string in `info` is NUL-terminated and outlives the
920        // call; `info` is correctly sized and owned by this frame.
921        if let Err(error) = unsafe { ShellExecuteExW(&raw mut info) } {
922            #[allow(clippy::cast_sign_loss)]
923            let code = (error.code().0 as u32) & 0xffff;
924            return if code == ERROR_CANCELLED.0 {
925                ElevationOutcome::Refused
926            } else {
927                ElevationOutcome::Unavailable(format!(
928                    "Windows could not show the administrator prompt: {}",
929                    error.message()
930                ))
931            };
932        }
933        if info.hProcess.is_invalid() {
934            return ElevationOutcome::Unavailable(
935                "Windows started no elevated process to wait for".into(),
936            );
937        }
938        // SAFETY: `hProcess` is a live process handle returned because
939        // `SEE_MASK_NOCLOSEPROCESS` was set; it is closed exactly once below.
940        let waited = unsafe { WaitForSingleObject(info.hProcess, ELEVATED_WAIT_MS) };
941        let mut code = 1u32;
942        let outcome = if waited == WAIT_OBJECT_0 {
943            // SAFETY: as above; `code` is a live out-parameter.
944            match unsafe { GetExitCodeProcess(info.hProcess, &raw mut code) } {
945                #[allow(clippy::cast_possible_wrap)]
946                Ok(()) => ElevationOutcome::Exited(code as i32),
947                Err(error) => ElevationOutcome::Unavailable(format!(
948                    "the elevated process's exit code could not be read: {}",
949                    error.message()
950                )),
951            }
952        } else {
953            ElevationOutcome::Unavailable(
954                "the elevated process did not finish within 15 minutes".into(),
955            )
956        };
957        // SAFETY: closed exactly once.
958        let _ = unsafe { CloseHandle(info.hProcess) };
959        outcome
960    }
961}
962
963// ---------------------------------------------------------------------------
964// macOS and Linux
965// ---------------------------------------------------------------------------
966
967#[cfg(unix)]
968mod sys {
969    use std::ffi::OsString;
970    use std::path::Path;
971    use std::process::Command;
972
973    /// No session to ask about: the terminal decides.
974    pub(super) const fn session_id() -> Option<u32> {
975        None
976    }
977
978    use super::{ElevationOutcome, RegistryError};
979
980    pub(super) fn is_elevated() -> bool {
981        // SAFETY: `geteuid` takes no argument and cannot fail.
982        unsafe { libc::geteuid() == 0 }
983    }
984
985    #[cfg(target_os = "macos")]
986    pub(super) fn physical_memory_bytes() -> Option<u64> {
987        let mut value = 0u64;
988        let mut size = std::mem::size_of::<u64>();
989        let name = c"hw.memsize";
990        // SAFETY: `name` is NUL-terminated; `value`/`size` describe a live
991        // 8-byte buffer owned by this frame; no new value is written.
992        let result = unsafe {
993            libc::sysctlbyname(
994                name.as_ptr(),
995                (&raw mut value).cast(),
996                &raw mut size,
997                std::ptr::null_mut(),
998                0,
999            )
1000        };
1001        (result == 0).then_some(value)
1002    }
1003
1004    #[cfg(not(target_os = "macos"))]
1005    pub(super) fn physical_memory_bytes() -> Option<u64> {
1006        let text = std::fs::read_to_string("/proc/meminfo").ok()?;
1007        let line = text.lines().find(|line| line.starts_with("MemTotal:"))?;
1008        let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
1009        kib.checked_mul(1024)
1010    }
1011
1012    fn unsupported() -> RegistryError {
1013        RegistryError::Other("the registry exists only on Windows".into())
1014    }
1015
1016    pub(super) fn read_dword(_: &str, _: &str) -> Result<Option<u32>, RegistryError> {
1017        Err(unsupported())
1018    }
1019
1020    pub(super) fn read_string(_: &str, _: &str) -> Result<Option<String>, RegistryError> {
1021        Err(unsupported())
1022    }
1023
1024    pub(super) fn value_names(_: &str) -> Result<Vec<String>, RegistryError> {
1025        Err(unsupported())
1026    }
1027
1028    pub(super) fn write_dword(_: &str, _: &str, _: Option<u32>) -> Result<(), RegistryError> {
1029        Err(unsupported())
1030    }
1031
1032    pub(super) fn write_string(_: &str, _: &str, _: Option<&str>) -> Result<(), RegistryError> {
1033        Err(unsupported())
1034    }
1035
1036    fn exit_outcome(
1037        status: std::io::Result<std::process::ExitStatus>,
1038        what: &str,
1039    ) -> ElevationOutcome {
1040        match status {
1041            Ok(status) => ElevationOutcome::Exited(status.code().unwrap_or(1)),
1042            Err(error) => {
1043                ElevationOutcome::Unavailable(format!("{what} could not be started: {error}"))
1044            }
1045        }
1046    }
1047
1048    pub(super) fn run_elevated(
1049        program: &Path,
1050        args: &[OsString],
1051        terminal: bool,
1052    ) -> ElevationOutcome {
1053        if terminal
1054            && super::find_on_path("sudo", &std::env::var_os("PATH").unwrap_or_default()).is_some()
1055        {
1056            let status = Command::new("sudo")
1057                .arg("--")
1058                .arg(program)
1059                .args(args)
1060                .status();
1061            // `sudo` exits 1 both for a refused password and for a child that
1062            // exited 1; the child never exits 1 (it reports through its result
1063            // file and exits 0 or 2), so 1 is read as a refusal.
1064            return match exit_outcome(status, "sudo") {
1065                ElevationOutcome::Exited(1) => ElevationOutcome::Refused,
1066                other => other,
1067            };
1068        }
1069        if cfg!(target_os = "macos") {
1070            let command = std::iter::once(program.as_os_str().to_owned())
1071                .chain(args.iter().cloned())
1072                .map(|part| super::quote_posix_argument(&part.to_string_lossy()))
1073                .collect::<Vec<_>>()
1074                .join(" ");
1075            let output = Command::new("/usr/bin/osascript")
1076                .arg("-e")
1077                .arg(super::administrator_applescript(&command))
1078                .output();
1079            return match output {
1080                Ok(output) if output.status.success() => ElevationOutcome::Exited(0),
1081                // -128 is "User canceled." in every locale.
1082                Ok(output) if String::from_utf8_lossy(&output.stderr).contains("(-128)") => {
1083                    ElevationOutcome::Refused
1084                }
1085                Ok(output) => {
1086                    let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1087                    if detail.contains("(-60007)") || detail.contains("(-1743)") {
1088                        ElevationOutcome::Unavailable(format!(
1089                            "macOS could not show the administrator dialog here: {detail}"
1090                        ))
1091                    } else {
1092                        ElevationOutcome::Exited(output.status.code().unwrap_or(2))
1093                    }
1094                }
1095                Err(error) => ElevationOutcome::Unavailable(format!(
1096                    "osascript could not be started: {error}"
1097                )),
1098            };
1099        }
1100        ElevationOutcome::Unavailable(
1101            "no terminal to ask for a sudo password; run the command from a terminal".into(),
1102        )
1103    }
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108    use super::*;
1109
1110    #[test]
1111    fn windows_arguments_survive_the_command_line_round_trip() {
1112        for (argument, expected) in [
1113            ("plain", "plain"),
1114            ("", "\"\""),
1115            ("with space", "\"with space\""),
1116            ("C:\\Program Files\\x\\", "\"C:\\Program Files\\x\\\\\""),
1117            ("say \"hi\"", "\"say \\\"hi\\\"\""),
1118            ("a\\\"b", "\"a\\\\\\\"b\""),
1119            ("{\"k\":\"C:\\\\r m\"}", "\"{\\\"k\\\":\\\"C:\\\\r m\\\"}\""),
1120        ] {
1121            assert_eq!(
1122                crate::service::quote_argument(argument),
1123                expected,
1124                "{argument}"
1125            );
1126        }
1127    }
1128
1129    #[test]
1130    fn posix_and_applescript_quoting_keep_hostile_characters_inert() {
1131        assert_eq!(quote_posix_argument("it's"), "'it'\\''s'");
1132        let script = administrator_applescript("'/a b/rm' 'x\"y' '\\'");
1133        assert_eq!(
1134            script,
1135            "do shell script \"'/a b/rm' 'x\\\"y' '\\\\'\" with administrator privileges"
1136        );
1137    }
1138
1139    #[test]
1140    fn a_symlink_probe_leaves_nothing_behind() {
1141        let directory = tempfile::tempdir().unwrap();
1142        let _ = can_create_symlink(directory.path()).unwrap();
1143        assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
1144    }
1145
1146    #[cfg(unix)]
1147    #[test]
1148    fn unix_accounts_can_always_create_symlinks() {
1149        let directory = tempfile::tempdir().unwrap();
1150        assert!(can_create_symlink(directory.path()).unwrap());
1151    }
1152
1153    #[test]
1154    fn a_tool_is_found_on_path_and_a_path_like_name_never_is() {
1155        let directory = tempfile::tempdir().unwrap();
1156        let name = if cfg!(windows) {
1157            "rmtool.exe"
1158        } else {
1159            "rmtool"
1160        };
1161        let tool = directory.path().join(name);
1162        std::fs::write(&tool, b"").unwrap();
1163        #[cfg(unix)]
1164        {
1165            use std::os::unix::fs::PermissionsExt as _;
1166            std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
1167        }
1168        let path = std::env::join_paths([directory.path()]).unwrap();
1169        assert_eq!(find_on_path("rmtool", &path), Some(tool));
1170        assert_eq!(find_on_path("absent-tool", &path), None);
1171        assert_eq!(find_on_path("../rmtool", &path), None);
1172    }
1173
1174    /// Node.js on Windows ships a POSIX `npm` script beside `npm.cmd`; only the
1175    /// one Windows can start is an answer.
1176    #[cfg(windows)]
1177    #[test]
1178    fn an_extensionless_file_is_never_the_windows_answer() {
1179        let directory = tempfile::tempdir().unwrap();
1180        std::fs::write(directory.path().join("npm"), b"#!/bin/sh\n").unwrap();
1181        let path = std::env::join_paths([directory.path()]).unwrap();
1182        assert_eq!(find_on_path("npm", &path), None);
1183        let cmd = directory.path().join("npm.cmd");
1184        std::fs::write(&cmd, b"@echo off\r\n").unwrap();
1185        assert_eq!(find_on_path("npm", &path), Some(cmd.clone()));
1186        assert_eq!(find_on_path("npm.cmd", &path), Some(cmd));
1187    }
1188
1189    fn unfit_finding(id: &str) -> UnfitFinding {
1190        UnfitFinding {
1191            id: id.into(),
1192            detail: "it fails".into(),
1193            remedy: None,
1194        }
1195    }
1196
1197    /// A UAC prompt is a desktop dialog: a session with a desktop can answer it
1198    /// with no terminal at all, and session 0 cannot even with one.
1199    #[test]
1200    fn who_can_answer_an_administrator_prompt() {
1201        assert!(
1202            prompt_answerable(Some(1), false),
1203            "the desktop, no terminal"
1204        );
1205        assert!(prompt_answerable(Some(2), true));
1206        assert!(!prompt_answerable(Some(0), true), "services and SSH");
1207        assert!(prompt_answerable(None, true), "sudo on a terminal");
1208        assert!(!prompt_answerable(None, false));
1209    }
1210
1211    /// A stalled daemon cannot re-stamp its refusal, and the refusal stands.
1212    #[test]
1213    fn an_unfit_record_stays_in_force_while_its_daemon_is_stalled() {
1214        let root = tempfile::tempdir().unwrap();
1215        let paths = AppPaths::rooted_at(root.path());
1216        paths.create_all().unwrap();
1217        let then = Utc::now();
1218        record_host_unfit(
1219            &paths,
1220            &[unfit_finding("host.runner_root_responsive")],
1221            then,
1222        )
1223        .unwrap();
1224        let later = then + chrono::Duration::hours(1);
1225        assert_eq!(
1226            host_unfit_in_force(&paths, later),
1227            None,
1228            "nobody beats: stopped"
1229        );
1230
1231        // The same process, still running, last heard from with the record.
1232        crate::daemon_heartbeat::beat(&paths, then).unwrap();
1233        assert!(host_unfit_in_force(&paths, later).is_some(), "stalled");
1234        crate::daemon_heartbeat::beat(&paths, later).unwrap();
1235        assert_eq!(
1236            host_unfit_in_force(&paths, later),
1237            None,
1238            "a daemon still beating would have re-stamped a refusal in force"
1239        );
1240
1241        // A record from long before the stalled daemon's last beat is another
1242        // generation's, and does not come back.
1243        let much_later = later + chrono::Duration::hours(1);
1244        assert_eq!(host_unfit_in_force(&paths, much_later), None);
1245    }
1246
1247    #[test]
1248    fn the_unfit_record_keeps_its_first_moment_and_clears() {
1249        let root = tempfile::tempdir().unwrap();
1250        let paths = AppPaths::rooted_at(root.path());
1251        paths.create_all().unwrap();
1252        assert_eq!(host_unfit(&paths).unwrap(), None);
1253        let first = Utc::now() - chrono::Duration::minutes(10);
1254        record_host_unfit(&paths, &[unfit_finding("windows.symlink_privilege")], first).unwrap();
1255        let later = Utc::now();
1256        record_host_unfit(&paths, &[unfit_finding("host.required_tools")], later).unwrap();
1257        let record = host_unfit(&paths).unwrap().unwrap();
1258        assert_eq!(record.since, first);
1259        assert_eq!(record.checked_at, later);
1260        assert_eq!(record.checks, ["host.required_tools"]);
1261        clear_host_unfit(&paths).unwrap();
1262        assert_eq!(host_unfit(&paths).unwrap(), None);
1263        clear_host_unfit(&paths).unwrap();
1264    }
1265
1266    #[test]
1267    fn physical_memory_is_reported() {
1268        assert!(physical_memory_bytes().is_some_and(|bytes| bytes > 256 * 1024 * 1024));
1269    }
1270
1271    #[test]
1272    fn a_directory_responds_and_a_not_yet_created_one_is_judged_by_its_parent() {
1273        let directory = tempfile::tempdir().unwrap();
1274        let deadline = std::time::Duration::from_secs(10);
1275        assert_eq!(
1276            directory_responds(directory.path(), deadline),
1277            Responsiveness::Responds
1278        );
1279        assert_eq!(
1280            directory_responds(&directory.path().join("not-yet").join("deeper"), deadline),
1281            Responsiveness::Responds
1282        );
1283        // Only this test's paths: the registry is shared with tests running
1284        // beside it.
1285        assert!(
1286            !PROBES_IN_FLIGHT
1287                .lock()
1288                .unwrap()
1289                .iter()
1290                .any(|(path, _)| path.starts_with(directory.path())),
1291            "a finished probe deregisters"
1292        );
1293    }
1294
1295    /// While an earlier probe of a path is still blocked, a new one answers at
1296    /// once instead of parking another thread on the same volume, and says
1297    /// what the stuck one is blocked on: a daemon re-checking every five
1298    /// minutes behind a pending privacy question keeps naming it.
1299    #[test]
1300    fn a_path_still_being_probed_answers_without_a_second_thread() {
1301        let directory = tempfile::tempdir().unwrap();
1302        for (name, listing, expected) in [
1303            ("stuck-volume", false, Responsiveness::Hung),
1304            ("asking-volume", true, Responsiveness::ListingBlocked),
1305        ] {
1306            let stuck = directory.path().join(name);
1307            PROBES_IN_FLIGHT.lock().unwrap().push((
1308                stuck.clone(),
1309                std::sync::Arc::new(std::sync::atomic::AtomicBool::new(listing)),
1310            ));
1311            let started = std::time::Instant::now();
1312            let answer = directory_responds(&stuck, std::time::Duration::from_secs(30));
1313            PROBES_IN_FLIGHT
1314                .lock()
1315                .unwrap()
1316                .retain(|(path, _)| *path != stuck);
1317            assert_eq!(answer, expected, "{name}");
1318            assert!(started.elapsed() < std::time::Duration::from_secs(5));
1319        }
1320    }
1321}