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/// Quotes one argument for a POSIX shell.
65#[must_use]
66pub fn quote_posix_argument(argument: &str) -> String {
67    format!("'{}'", argument.replace('\'', "'\\''"))
68}
69
70/// The AppleScript that runs `command` (already a shell command line) with
71/// administrator privileges.
72#[must_use]
73pub fn administrator_applescript(command: &str) -> String {
74    let escaped = command.replace('\\', "\\\\").replace('"', "\\\"");
75    format!("do shell script \"{escaped}\" with administrator privileges")
76}
77
78// ---------------------------------------------------------------------------
79// Read-only facts
80// ---------------------------------------------------------------------------
81
82/// Total physical memory, in bytes.
83#[must_use]
84pub fn physical_memory_bytes() -> Option<u64> {
85    sys::physical_memory_bytes()
86}
87
88/// The Windows system directory, from the kernel rather than from the
89/// writable `%SystemRoot%`: an elevated fix starts `powershell.exe` from it.
90///
91/// # Errors
92/// The system's error when the directory cannot be reported.
93#[cfg(windows)]
94pub fn system_directory() -> std::io::Result<PathBuf> {
95    crate::runner_root::windows_system_directory().map(PathBuf::from)
96}
97
98/// Replaces `path` with `bytes` atomically: a temporary file beside it, flushed
99/// to disk, then renamed over it, so a reader never sees half a file.
100///
101/// # Errors
102/// The I/O error of any step.
103pub fn write_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
104    use std::io::Write as _;
105    let parent = path.parent().unwrap_or_else(|| Path::new("."));
106    std::fs::create_dir_all(parent)?;
107    let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
108    temporary.write_all(bytes)?;
109    temporary.as_file().sync_all()?;
110    temporary
111        .persist(path)
112        .map(|_| ())
113        .map_err(|error| error.error)
114}
115
116/// How a bounded look at a directory went.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum Responsiveness {
119    /// It answered a `stat` and the first entry of a listing in time.
120    Responds,
121    /// It did not answer within the deadline: a hung volume (a stalled USB or
122    /// NVMe enclosure, a dead network mount).
123    Hung,
124    /// It answered with an error.
125    Failed(String),
126}
127
128/// Paths a [`directory_responds`] probe is still blocked on, so a volume that
129/// stays hung costs one stuck thread rather than one per probe.
130static PROBES_IN_FLIGHT: std::sync::Mutex<Vec<PathBuf>> = std::sync::Mutex::new(Vec::new());
131
132/// Whether `directory` (or, when it does not exist yet, its nearest existing
133/// ancestor) answers a `stat` and a one-entry listing within `deadline`.
134///
135/// The look runs on its own thread because a call into a hung filesystem does
136/// not return and cannot be cancelled. That thread is left blocked when the
137/// deadline passes, and while it is, later probes of the same path report
138/// [`Responsiveness::Hung`] at once instead of stacking more threads on it.
139#[must_use]
140pub fn directory_responds(directory: &Path, deadline: std::time::Duration) -> Responsiveness {
141    let path = directory.to_path_buf();
142    {
143        let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() else {
144            return Responsiveness::Failed("the probe registry is poisoned".into());
145        };
146        if in_flight.contains(&path) {
147            return Responsiveness::Hung;
148        }
149        in_flight.push(path.clone());
150    }
151    let (sender, receiver) = std::sync::mpsc::channel();
152    let probed = path.clone();
153    let spawned = std::thread::Builder::new()
154        .name("runner-root-probe".into())
155        .spawn(move || {
156            let existing = probed
157                .ancestors()
158                .find(|ancestor| std::fs::symlink_metadata(ancestor).is_ok())
159                .map(Path::to_path_buf);
160            let answer = match existing {
161                None => Responsiveness::Failed(format!("{} does not exist", probed.display())),
162                Some(existing) => match std::fs::read_dir(&existing) {
163                    Ok(mut entries) => match entries.next() {
164                        Some(Err(error)) => Responsiveness::Failed(error.to_string()),
165                        _ => Responsiveness::Responds,
166                    },
167                    Err(error) => Responsiveness::Failed(error.to_string()),
168                },
169            };
170            if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
171                in_flight.retain(|candidate| *candidate != probed);
172            }
173            let _ = sender.send(answer);
174        });
175    if let Err(error) = spawned {
176        if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
177            in_flight.retain(|candidate| *candidate != path);
178        }
179        return Responsiveness::Failed(error.to_string());
180    }
181    receiver
182        .recv_timeout(deadline)
183        .unwrap_or(Responsiveness::Hung)
184}
185
186/// Whether this process can create a symbolic link inside `directory`.
187///
188/// Measured rather than inferred: a link is created and removed again. On
189/// Windows this answers for the account and token this process runs with,
190/// which for the daemon is exactly the account runners inherit; a file link
191/// is what `npm`, `pnpm` and `setup-bun` create, and it needs either
192/// `SeCreateSymbolicLinkPrivilege` or Developer Mode.
193///
194/// # Errors
195/// The I/O error when the probe's target file cannot even be created, which
196/// says nothing about links.
197pub fn can_create_symlink(directory: &Path) -> std::io::Result<bool> {
198    std::fs::create_dir_all(directory)?;
199    let unique = format!(
200        ".rm-symlink-probe-{}-{}",
201        std::process::id(),
202        uuid::Uuid::new_v4().simple()
203    );
204    let target = directory.join(format!("{unique}.target"));
205    let link = directory.join(format!("{unique}.link"));
206    std::fs::write(&target, b"")?;
207    let created = symlink_file(&target, &link);
208    let _ = std::fs::remove_file(&link);
209    let _ = std::fs::remove_file(&target);
210    Ok(created.is_ok())
211}
212
213#[cfg(windows)]
214fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
215    std::os::windows::fs::symlink_file(target, link)
216}
217
218#[cfg(unix)]
219fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
220    std::os::unix::fs::symlink(target, link)
221}
222
223/// Finds `tool` on `path` the way a shell would, including `PATHEXT` on
224/// Windows. A name holding a path separator is never searched.
225#[must_use]
226pub fn find_on_path(tool: &str, path: &OsStr) -> Option<PathBuf> {
227    if tool.is_empty() || tool.contains(['/', '\\']) {
228        return None;
229    }
230    let extensions: Vec<String> = if cfg!(windows) {
231        let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
232        // A bare name is tried as-is only when it already carries an
233        // extension: `cmd` and `CreateProcess` never run an extensionless file,
234        // and Node.js ships a POSIX `npm` script beside `npm.cmd`.
235        let mut list = Vec::new();
236        if tool.contains('.') {
237            list.push(String::new());
238        }
239        list.extend(
240            pathext
241                .split(';')
242                .filter(|ext| !ext.is_empty())
243                .map(str::to_ascii_lowercase),
244        );
245        list
246    } else {
247        vec![String::new()]
248    };
249    std::env::split_paths(path)
250        .filter(|dir| !dir.as_os_str().is_empty())
251        .find_map(|dir| {
252            extensions.iter().find_map(|extension| {
253                let candidate = dir.join(format!("{tool}{extension}"));
254                is_executable(&candidate).then_some(candidate)
255            })
256        })
257}
258
259#[cfg(unix)]
260fn is_executable(path: &Path) -> bool {
261    use std::os::unix::fs::PermissionsExt as _;
262    std::fs::metadata(path)
263        .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
264}
265
266#[cfg(windows)]
267fn is_executable(path: &Path) -> bool {
268    std::fs::metadata(path).is_ok_and(|meta| meta.is_file())
269}
270
271// ---------------------------------------------------------------------------
272// The Windows registry (HKEY_LOCAL_MACHINE only)
273// ---------------------------------------------------------------------------
274
275/// Why a registry read or write failed.
276#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
277pub enum RegistryError {
278    /// The key exists and this token may not read (or write) it. Defender's
279    /// exclusion list answers this to every non-administrator.
280    #[error("access denied")]
281    AccessDenied,
282    /// Any other failure, with the system's code.
283    #[error("{0}")]
284    Other(String),
285}
286
287/// Reads a `REG_DWORD` under `HKLM\subkey`; `None` when the key or value is
288/// absent.
289///
290/// # Errors
291/// [`RegistryError`] when it exists and cannot be read.
292pub fn read_registry_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
293    sys::read_dword(subkey, value)
294}
295
296/// Reads a `REG_SZ` or `REG_EXPAND_SZ` (unexpanded) under `HKLM\subkey`.
297///
298/// # Errors
299/// [`RegistryError`] when it exists and cannot be read.
300pub fn read_registry_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
301    sys::read_string(subkey, value)
302}
303
304/// The names of every value under `HKLM\subkey`; an empty list when the key
305/// does not exist.
306///
307/// # Errors
308/// [`RegistryError::AccessDenied`] when the key may not be read.
309pub fn registry_value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
310    sys::value_names(subkey)
311}
312
313/// Writes (`Some`) or deletes (`None`) a `REG_DWORD` under `HKLM\subkey`,
314/// creating the key when needed. Needs administrator rights.
315///
316/// # Errors
317/// [`RegistryError`] when the write is refused.
318pub fn write_registry_dword(
319    subkey: &str,
320    value: &str,
321    data: Option<u32>,
322) -> Result<(), RegistryError> {
323    sys::write_dword(subkey, value, data)
324}
325
326/// Writes (`Some`) or deletes (`None`) a `REG_SZ` under `HKLM\subkey`. Needs
327/// administrator rights.
328///
329/// # Errors
330/// [`RegistryError`] when the write is refused.
331pub fn write_registry_string(
332    subkey: &str,
333    value: &str,
334    data: Option<&str>,
335) -> Result<(), RegistryError> {
336    sys::write_string(subkey, value, data)
337}
338
339// ---------------------------------------------------------------------------
340// The daemon's host-unfit record
341// ---------------------------------------------------------------------------
342
343/// The file under `state/` the daemon keeps while it refuses to start runners.
344pub const HOST_UNFIT_FILE: &str = "host-unfit.json";
345const HOST_UNFIT_SCHEMA_VERSION: u32 = 1;
346
347/// What the daemon recorded the last time a required host check failed.
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349pub struct HostUnfitRecord {
350    pub schema_version: u32,
351    /// The first moment of the ongoing refusal.
352    pub since: DateTime<Utc>,
353    /// When the checks were last evaluated.
354    pub checked_at: DateTime<Utc>,
355    /// The ids of the required checks that failed.
356    pub checks: Vec<String>,
357}
358
359fn host_unfit_path(paths: &AppPaths) -> PathBuf {
360    paths.state_dir().join(HOST_UNFIT_FILE)
361}
362
363/// Records that required checks failed, keeping the first moment of an
364/// ongoing refusal.
365///
366/// # Errors
367/// A description of the write failure.
368pub fn record_host_unfit(
369    paths: &AppPaths,
370    checks: &[String],
371    at: DateTime<Utc>,
372) -> Result<(), String> {
373    let since = host_unfit(paths)
374        .ok()
375        .flatten()
376        .map_or(at, |record| record.since);
377    let record = HostUnfitRecord {
378        schema_version: HOST_UNFIT_SCHEMA_VERSION,
379        since,
380        checked_at: at,
381        checks: checks.to_vec(),
382    };
383    let path = host_unfit_path(paths);
384    let text = serde_json::to_vec_pretty(&record).map_err(|error| error.to_string())?;
385    write_atomically(&path, &text)
386        .map_err(|error| format!("cannot write {}: {error}", path.display()))
387}
388
389/// Removes the record once every required check passes.
390///
391/// # Errors
392/// A description of the failure when the record exists and cannot be removed.
393pub fn clear_host_unfit(paths: &AppPaths) -> Result<(), String> {
394    let path = host_unfit_path(paths);
395    match std::fs::remove_file(&path) {
396        Ok(()) => Ok(()),
397        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
398        Err(error) => Err(format!("cannot remove {}: {error}", path.display())),
399    }
400}
401
402/// The daemon's current refusal, if it has one.
403///
404/// # Errors
405/// A description of the failure when the file exists and cannot be read.
406pub fn host_unfit(paths: &AppPaths) -> Result<Option<HostUnfitRecord>, String> {
407    let path = host_unfit_path(paths);
408    match std::fs::read_to_string(&path) {
409        Ok(text) => serde_json::from_str(&text)
410            .map(Some)
411            .map_err(|error| format!("cannot parse {}: {error}", path.display())),
412        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
413        Err(error) => Err(format!("cannot read {}: {error}", path.display())),
414    }
415}
416
417// ---------------------------------------------------------------------------
418// Windows
419// ---------------------------------------------------------------------------
420
421#[cfg(windows)]
422mod sys {
423    use std::ffi::OsString;
424    use std::os::windows::ffi::OsStrExt as _;
425    use std::path::Path;
426
427    use windows::Win32::Foundation::{
428        CloseHandle, ERROR_ACCESS_DENIED, ERROR_CANCELLED, ERROR_FILE_NOT_FOUND,
429        ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, HANDLE, WAIT_OBJECT_0, WIN32_ERROR,
430    };
431    use windows::Win32::Security::{
432        GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation,
433    };
434    use windows::Win32::System::Registry::{
435        HKEY, HKEY_LOCAL_MACHINE, KEY_READ, KEY_SET_VALUE, REG_DWORD, REG_OPTION_NON_VOLATILE,
436        REG_SZ, RRF_NOEXPAND, RRF_RT_REG_DWORD, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey,
437        RegCreateKeyExW, RegDeleteKeyValueW, RegEnumValueW, RegGetValueW, RegOpenKeyExW,
438        RegSetValueExW,
439    };
440    use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
441    use windows::Win32::System::Threading::{
442        GetCurrentProcess, GetExitCodeProcess, OpenProcessToken, WaitForSingleObject,
443    };
444    use windows::Win32::UI::Shell::{
445        SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW,
446    };
447    use windows::core::{PCWSTR, PWSTR};
448
449    use super::{ElevationOutcome, RegistryError};
450
451    /// `SW_HIDE`. Spelled here rather than pulling in the whole
452    /// `WindowsAndMessaging` module for one integer.
453    const SW_HIDE: i32 = 0;
454    /// The longest an elevated fix batch is waited for. Generous: Defender
455    /// and `git config` answer in seconds, and a person may take a while to
456    /// read the UAC prompt.
457    const ELEVATED_WAIT_MS: u32 = 15 * 60 * 1000;
458
459    fn wide(text: &str) -> Vec<u16> {
460        text.encode_utf16().chain(std::iter::once(0)).collect()
461    }
462
463    fn registry_error(code: WIN32_ERROR) -> RegistryError {
464        if code == ERROR_ACCESS_DENIED {
465            RegistryError::AccessDenied
466        } else {
467            RegistryError::Other(format!("registry error {}", code.0))
468        }
469    }
470
471    pub(super) fn is_elevated() -> bool {
472        let mut token = HANDLE::default();
473        // SAFETY: the pseudo-handle from `GetCurrentProcess` needs no closing;
474        // `token` is a live out-parameter owned by this frame.
475        if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
476            return false;
477        }
478        let mut elevation = TOKEN_ELEVATION::default();
479        let mut returned = 0u32;
480        // SAFETY: `elevation` is a correctly sized `TOKEN_ELEVATION` owned by
481        // this frame, and its size is the length passed.
482        let queried = unsafe {
483            GetTokenInformation(
484                token,
485                TokenElevation,
486                Some((&raw mut elevation).cast()),
487                u32::try_from(std::mem::size_of::<TOKEN_ELEVATION>()).unwrap_or(4),
488                &raw mut returned,
489            )
490        };
491        // SAFETY: `token` was opened above and is closed exactly once.
492        let _ = unsafe { CloseHandle(token) };
493        queried.is_ok() && elevation.TokenIsElevated != 0
494    }
495
496    pub(super) fn physical_memory_bytes() -> Option<u64> {
497        let mut status = MEMORYSTATUSEX {
498            dwLength: u32::try_from(std::mem::size_of::<MEMORYSTATUSEX>()).ok()?,
499            ..MEMORYSTATUSEX::default()
500        };
501        // SAFETY: `status` is a correctly sized, length-stamped structure
502        // owned by this frame.
503        unsafe { GlobalMemoryStatusEx(&raw mut status) }.ok()?;
504        Some(status.ullTotalPhys)
505    }
506
507    pub(super) fn read_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
508        let subkey = wide(subkey);
509        let value = wide(value);
510        let mut data = 0u32;
511        let mut size = 4u32;
512        // SAFETY: both names are NUL-terminated and outlive the call; `data`
513        // and `size` are live and describe a 4-byte buffer.
514        let code = unsafe {
515            RegGetValueW(
516                HKEY_LOCAL_MACHINE,
517                PCWSTR(subkey.as_ptr()),
518                PCWSTR(value.as_ptr()),
519                RRF_RT_REG_DWORD,
520                None,
521                Some((&raw mut data).cast()),
522                Some(&raw mut size),
523            )
524        };
525        match code {
526            ERROR_SUCCESS => Ok(Some(data)),
527            ERROR_FILE_NOT_FOUND => Ok(None),
528            other => Err(registry_error(other)),
529        }
530    }
531
532    pub(super) fn read_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
533        let subkey = wide(subkey);
534        let value = wide(value);
535        let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ | RRF_NOEXPAND;
536        let mut size = 0u32;
537        // SAFETY: a size query; both names are NUL-terminated and outlive it.
538        let code = unsafe {
539            RegGetValueW(
540                HKEY_LOCAL_MACHINE,
541                PCWSTR(subkey.as_ptr()),
542                PCWSTR(value.as_ptr()),
543                flags,
544                None,
545                None,
546                Some(&raw mut size),
547            )
548        };
549        match code {
550            ERROR_SUCCESS => {}
551            ERROR_FILE_NOT_FOUND => return Ok(None),
552            other => return Err(registry_error(other)),
553        }
554        let mut buffer = vec![0u16; (size as usize).div_ceil(2) + 1];
555        let mut size = u32::try_from(buffer.len() * 2).unwrap_or(u32::MAX);
556        // SAFETY: `buffer` is owned by this frame and `size` is its length in
557        // bytes.
558        let code = unsafe {
559            RegGetValueW(
560                HKEY_LOCAL_MACHINE,
561                PCWSTR(subkey.as_ptr()),
562                PCWSTR(value.as_ptr()),
563                flags,
564                None,
565                Some(buffer.as_mut_ptr().cast()),
566                Some(&raw mut size),
567            )
568        };
569        match code {
570            ERROR_SUCCESS => {
571                let units = (size as usize / 2).min(buffer.len());
572                let text = String::from_utf16_lossy(&buffer[..units]);
573                Ok(Some(text.trim_end_matches('\0').to_owned()))
574            }
575            ERROR_FILE_NOT_FOUND => Ok(None),
576            other => Err(registry_error(other)),
577        }
578    }
579
580    pub(super) fn value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
581        let subkey = wide(subkey);
582        let mut key = HKEY::default();
583        // SAFETY: `subkey` is NUL-terminated and `key` is a live out-parameter.
584        let code = unsafe {
585            RegOpenKeyExW(
586                HKEY_LOCAL_MACHINE,
587                PCWSTR(subkey.as_ptr()),
588                None,
589                KEY_READ,
590                &raw mut key,
591            )
592        };
593        match code {
594            ERROR_SUCCESS => {}
595            ERROR_FILE_NOT_FOUND => return Ok(Vec::new()),
596            other => return Err(registry_error(other)),
597        }
598        let mut names = Vec::new();
599        let mut index = 0u32;
600        let result = loop {
601            // 16,383 characters is the documented maximum value-name length.
602            let mut name = vec![0u16; 16_384];
603            let mut length = u32::try_from(name.len()).unwrap_or(u32::MAX);
604            // SAFETY: `name` is owned by this frame and `length` is its size
605            // in characters; every other out-parameter is omitted.
606            let code = unsafe {
607                RegEnumValueW(
608                    key,
609                    index,
610                    Some(PWSTR(name.as_mut_ptr())),
611                    &raw mut length,
612                    None,
613                    None,
614                    None,
615                    None,
616                )
617            };
618            match code {
619                ERROR_SUCCESS => {
620                    names.push(String::from_utf16_lossy(&name[..length as usize]));
621                    index += 1;
622                }
623                ERROR_NO_MORE_ITEMS => break Ok(names),
624                other => break Err(registry_error(other)),
625            }
626        };
627        // SAFETY: `key` was opened above and is closed exactly once.
628        let _ = unsafe { RegCloseKey(key) };
629        result
630    }
631
632    fn write_value(
633        subkey: &str,
634        value: &str,
635        data: Option<(windows::Win32::System::Registry::REG_VALUE_TYPE, Vec<u8>)>,
636    ) -> Result<(), RegistryError> {
637        let subkey_wide = wide(subkey);
638        let value_wide = wide(value);
639        let Some((kind, bytes)) = data else {
640            // SAFETY: both names are NUL-terminated and outlive the call.
641            let code = unsafe {
642                RegDeleteKeyValueW(
643                    HKEY_LOCAL_MACHINE,
644                    PCWSTR(subkey_wide.as_ptr()),
645                    PCWSTR(value_wide.as_ptr()),
646                )
647            };
648            return match code {
649                ERROR_SUCCESS | ERROR_FILE_NOT_FOUND => Ok(()),
650                other => Err(registry_error(other)),
651            };
652        };
653        let mut key = HKEY::default();
654        // SAFETY: `subkey_wide` is NUL-terminated; `key` is a live
655        // out-parameter; no class, security attributes or disposition.
656        let code = unsafe {
657            RegCreateKeyExW(
658                HKEY_LOCAL_MACHINE,
659                PCWSTR(subkey_wide.as_ptr()),
660                None,
661                PCWSTR::null(),
662                REG_OPTION_NON_VOLATILE,
663                KEY_SET_VALUE,
664                None,
665                &raw mut key,
666                None,
667            )
668        };
669        if code != ERROR_SUCCESS {
670            return Err(registry_error(code));
671        }
672        // SAFETY: `key` is open for `KEY_SET_VALUE`; the value name is
673        // NUL-terminated and `bytes` outlives the call.
674        let code =
675            unsafe { RegSetValueExW(key, PCWSTR(value_wide.as_ptr()), None, kind, Some(&bytes)) };
676        // SAFETY: `key` was opened above and is closed exactly once.
677        let _ = unsafe { RegCloseKey(key) };
678        if code == ERROR_SUCCESS {
679            Ok(())
680        } else {
681            Err(registry_error(code))
682        }
683    }
684
685    pub(super) fn write_dword(
686        subkey: &str,
687        value: &str,
688        data: Option<u32>,
689    ) -> Result<(), RegistryError> {
690        write_value(
691            subkey,
692            value,
693            data.map(|data| (REG_DWORD, data.to_le_bytes().to_vec())),
694        )
695    }
696
697    pub(super) fn write_string(
698        subkey: &str,
699        value: &str,
700        data: Option<&str>,
701    ) -> Result<(), RegistryError> {
702        write_value(
703            subkey,
704            value,
705            data.map(|text| {
706                let bytes = wide(text)
707                    .iter()
708                    .flat_map(|unit| unit.to_le_bytes())
709                    .collect();
710                (REG_SZ, bytes)
711            }),
712        )
713    }
714
715    pub(super) fn run_elevated(
716        program: &Path,
717        args: &[OsString],
718        _terminal: bool,
719    ) -> ElevationOutcome {
720        let file: Vec<u16> = program
721            .as_os_str()
722            .encode_wide()
723            .chain(std::iter::once(0))
724            .collect();
725        let parameters = args
726            .iter()
727            .map(|argument| crate::service::quote_argument(&argument.to_string_lossy()))
728            .collect::<Vec<_>>()
729            .join(" ");
730        let parameters = wide(&parameters);
731        let verb = wide("runas");
732        let mut info = SHELLEXECUTEINFOW {
733            cbSize: u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).unwrap_or(0),
734            fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC,
735            lpVerb: PCWSTR(verb.as_ptr()),
736            lpFile: PCWSTR(file.as_ptr()),
737            lpParameters: PCWSTR(parameters.as_ptr()),
738            nShow: SW_HIDE,
739            ..SHELLEXECUTEINFOW::default()
740        };
741        // SAFETY: every string in `info` is NUL-terminated and outlives the
742        // call; `info` is correctly sized and owned by this frame.
743        if let Err(error) = unsafe { ShellExecuteExW(&raw mut info) } {
744            #[allow(clippy::cast_sign_loss)]
745            let code = (error.code().0 as u32) & 0xffff;
746            return if code == ERROR_CANCELLED.0 {
747                ElevationOutcome::Refused
748            } else {
749                ElevationOutcome::Unavailable(format!(
750                    "Windows could not show the administrator prompt: {}",
751                    error.message()
752                ))
753            };
754        }
755        if info.hProcess.is_invalid() {
756            return ElevationOutcome::Unavailable(
757                "Windows started no elevated process to wait for".into(),
758            );
759        }
760        // SAFETY: `hProcess` is a live process handle returned because
761        // `SEE_MASK_NOCLOSEPROCESS` was set; it is closed exactly once below.
762        let waited = unsafe { WaitForSingleObject(info.hProcess, ELEVATED_WAIT_MS) };
763        let mut code = 1u32;
764        let outcome = if waited == WAIT_OBJECT_0 {
765            // SAFETY: as above; `code` is a live out-parameter.
766            match unsafe { GetExitCodeProcess(info.hProcess, &raw mut code) } {
767                #[allow(clippy::cast_possible_wrap)]
768                Ok(()) => ElevationOutcome::Exited(code as i32),
769                Err(error) => ElevationOutcome::Unavailable(format!(
770                    "the elevated process's exit code could not be read: {}",
771                    error.message()
772                )),
773            }
774        } else {
775            ElevationOutcome::Unavailable(
776                "the elevated process did not finish within 15 minutes".into(),
777            )
778        };
779        // SAFETY: closed exactly once.
780        let _ = unsafe { CloseHandle(info.hProcess) };
781        outcome
782    }
783}
784
785// ---------------------------------------------------------------------------
786// macOS and Linux
787// ---------------------------------------------------------------------------
788
789#[cfg(unix)]
790mod sys {
791    use std::ffi::OsString;
792    use std::path::Path;
793    use std::process::Command;
794
795    use super::{ElevationOutcome, RegistryError};
796
797    pub(super) fn is_elevated() -> bool {
798        // SAFETY: `geteuid` takes no argument and cannot fail.
799        unsafe { libc::geteuid() == 0 }
800    }
801
802    #[cfg(target_os = "macos")]
803    pub(super) fn physical_memory_bytes() -> Option<u64> {
804        let mut value = 0u64;
805        let mut size = std::mem::size_of::<u64>();
806        let name = c"hw.memsize";
807        // SAFETY: `name` is NUL-terminated; `value`/`size` describe a live
808        // 8-byte buffer owned by this frame; no new value is written.
809        let result = unsafe {
810            libc::sysctlbyname(
811                name.as_ptr(),
812                (&raw mut value).cast(),
813                &raw mut size,
814                std::ptr::null_mut(),
815                0,
816            )
817        };
818        (result == 0).then_some(value)
819    }
820
821    #[cfg(not(target_os = "macos"))]
822    pub(super) fn physical_memory_bytes() -> Option<u64> {
823        let text = std::fs::read_to_string("/proc/meminfo").ok()?;
824        let line = text.lines().find(|line| line.starts_with("MemTotal:"))?;
825        let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
826        kib.checked_mul(1024)
827    }
828
829    fn unsupported() -> RegistryError {
830        RegistryError::Other("the registry exists only on Windows".into())
831    }
832
833    pub(super) fn read_dword(_: &str, _: &str) -> Result<Option<u32>, RegistryError> {
834        Err(unsupported())
835    }
836
837    pub(super) fn read_string(_: &str, _: &str) -> Result<Option<String>, RegistryError> {
838        Err(unsupported())
839    }
840
841    pub(super) fn value_names(_: &str) -> Result<Vec<String>, RegistryError> {
842        Err(unsupported())
843    }
844
845    pub(super) fn write_dword(_: &str, _: &str, _: Option<u32>) -> Result<(), RegistryError> {
846        Err(unsupported())
847    }
848
849    pub(super) fn write_string(_: &str, _: &str, _: Option<&str>) -> Result<(), RegistryError> {
850        Err(unsupported())
851    }
852
853    fn exit_outcome(
854        status: std::io::Result<std::process::ExitStatus>,
855        what: &str,
856    ) -> ElevationOutcome {
857        match status {
858            Ok(status) => ElevationOutcome::Exited(status.code().unwrap_or(1)),
859            Err(error) => {
860                ElevationOutcome::Unavailable(format!("{what} could not be started: {error}"))
861            }
862        }
863    }
864
865    pub(super) fn run_elevated(
866        program: &Path,
867        args: &[OsString],
868        terminal: bool,
869    ) -> ElevationOutcome {
870        if terminal
871            && super::find_on_path("sudo", &std::env::var_os("PATH").unwrap_or_default()).is_some()
872        {
873            let status = Command::new("sudo")
874                .arg("--")
875                .arg(program)
876                .args(args)
877                .status();
878            // `sudo` exits 1 both for a refused password and for a child that
879            // exited 1; the child never exits 1 (it reports through its result
880            // file and exits 0 or 2), so 1 is read as a refusal.
881            return match exit_outcome(status, "sudo") {
882                ElevationOutcome::Exited(1) => ElevationOutcome::Refused,
883                other => other,
884            };
885        }
886        if cfg!(target_os = "macos") {
887            let command = std::iter::once(program.as_os_str().to_owned())
888                .chain(args.iter().cloned())
889                .map(|part| super::quote_posix_argument(&part.to_string_lossy()))
890                .collect::<Vec<_>>()
891                .join(" ");
892            let output = Command::new("/usr/bin/osascript")
893                .arg("-e")
894                .arg(super::administrator_applescript(&command))
895                .output();
896            return match output {
897                Ok(output) if output.status.success() => ElevationOutcome::Exited(0),
898                // -128 is "User canceled." in every locale.
899                Ok(output) if String::from_utf8_lossy(&output.stderr).contains("(-128)") => {
900                    ElevationOutcome::Refused
901                }
902                Ok(output) => {
903                    let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned();
904                    if detail.contains("(-60007)") || detail.contains("(-1743)") {
905                        ElevationOutcome::Unavailable(format!(
906                            "macOS could not show the administrator dialog here: {detail}"
907                        ))
908                    } else {
909                        ElevationOutcome::Exited(output.status.code().unwrap_or(2))
910                    }
911                }
912                Err(error) => ElevationOutcome::Unavailable(format!(
913                    "osascript could not be started: {error}"
914                )),
915            };
916        }
917        ElevationOutcome::Unavailable(
918            "no terminal to ask for a sudo password; run the command from a terminal".into(),
919        )
920    }
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    #[test]
928    fn windows_arguments_survive_the_command_line_round_trip() {
929        for (argument, expected) in [
930            ("plain", "plain"),
931            ("", "\"\""),
932            ("with space", "\"with space\""),
933            ("C:\\Program Files\\x\\", "\"C:\\Program Files\\x\\\\\""),
934            ("say \"hi\"", "\"say \\\"hi\\\"\""),
935            ("a\\\"b", "\"a\\\\\\\"b\""),
936            ("{\"k\":\"C:\\\\r m\"}", "\"{\\\"k\\\":\\\"C:\\\\r m\\\"}\""),
937        ] {
938            assert_eq!(
939                crate::service::quote_argument(argument),
940                expected,
941                "{argument}"
942            );
943        }
944    }
945
946    #[test]
947    fn posix_and_applescript_quoting_keep_hostile_characters_inert() {
948        assert_eq!(quote_posix_argument("it's"), "'it'\\''s'");
949        let script = administrator_applescript("'/a b/rm' 'x\"y' '\\'");
950        assert_eq!(
951            script,
952            "do shell script \"'/a b/rm' 'x\\\"y' '\\\\'\" with administrator privileges"
953        );
954    }
955
956    #[test]
957    fn a_symlink_probe_leaves_nothing_behind() {
958        let directory = tempfile::tempdir().unwrap();
959        let _ = can_create_symlink(directory.path()).unwrap();
960        assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
961    }
962
963    #[cfg(unix)]
964    #[test]
965    fn unix_accounts_can_always_create_symlinks() {
966        let directory = tempfile::tempdir().unwrap();
967        assert!(can_create_symlink(directory.path()).unwrap());
968    }
969
970    #[test]
971    fn a_tool_is_found_on_path_and_a_path_like_name_never_is() {
972        let directory = tempfile::tempdir().unwrap();
973        let name = if cfg!(windows) {
974            "rmtool.exe"
975        } else {
976            "rmtool"
977        };
978        let tool = directory.path().join(name);
979        std::fs::write(&tool, b"").unwrap();
980        #[cfg(unix)]
981        {
982            use std::os::unix::fs::PermissionsExt as _;
983            std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
984        }
985        let path = std::env::join_paths([directory.path()]).unwrap();
986        assert_eq!(find_on_path("rmtool", &path), Some(tool));
987        assert_eq!(find_on_path("absent-tool", &path), None);
988        assert_eq!(find_on_path("../rmtool", &path), None);
989    }
990
991    /// Node.js on Windows ships a POSIX `npm` script beside `npm.cmd`; only the
992    /// one Windows can start is an answer.
993    #[cfg(windows)]
994    #[test]
995    fn an_extensionless_file_is_never_the_windows_answer() {
996        let directory = tempfile::tempdir().unwrap();
997        std::fs::write(directory.path().join("npm"), b"#!/bin/sh\n").unwrap();
998        let path = std::env::join_paths([directory.path()]).unwrap();
999        assert_eq!(find_on_path("npm", &path), None);
1000        let cmd = directory.path().join("npm.cmd");
1001        std::fs::write(&cmd, b"@echo off\r\n").unwrap();
1002        assert_eq!(find_on_path("npm", &path), Some(cmd.clone()));
1003        assert_eq!(find_on_path("npm.cmd", &path), Some(cmd));
1004    }
1005
1006    #[test]
1007    fn the_unfit_record_keeps_its_first_moment_and_clears() {
1008        let root = tempfile::tempdir().unwrap();
1009        let paths = AppPaths::rooted_at(root.path());
1010        paths.create_all().unwrap();
1011        assert_eq!(host_unfit(&paths).unwrap(), None);
1012        let first = Utc::now() - chrono::Duration::minutes(10);
1013        record_host_unfit(&paths, &["windows.symlink_privilege".into()], first).unwrap();
1014        let later = Utc::now();
1015        record_host_unfit(&paths, &["host.required_tools".into()], later).unwrap();
1016        let record = host_unfit(&paths).unwrap().unwrap();
1017        assert_eq!(record.since, first);
1018        assert_eq!(record.checked_at, later);
1019        assert_eq!(record.checks, ["host.required_tools"]);
1020        clear_host_unfit(&paths).unwrap();
1021        assert_eq!(host_unfit(&paths).unwrap(), None);
1022        clear_host_unfit(&paths).unwrap();
1023    }
1024
1025    #[test]
1026    fn physical_memory_is_reported() {
1027        assert!(physical_memory_bytes().is_some_and(|bytes| bytes > 256 * 1024 * 1024));
1028    }
1029
1030    #[test]
1031    fn a_directory_responds_and_a_not_yet_created_one_is_judged_by_its_parent() {
1032        let directory = tempfile::tempdir().unwrap();
1033        let deadline = std::time::Duration::from_secs(10);
1034        assert_eq!(
1035            directory_responds(directory.path(), deadline),
1036            Responsiveness::Responds
1037        );
1038        assert_eq!(
1039            directory_responds(&directory.path().join("not-yet").join("deeper"), deadline),
1040            Responsiveness::Responds
1041        );
1042        // Only this test's paths: the registry is shared with tests running
1043        // beside it.
1044        assert!(
1045            !PROBES_IN_FLIGHT
1046                .lock()
1047                .unwrap()
1048                .iter()
1049                .any(|path| path.starts_with(directory.path())),
1050            "a finished probe deregisters"
1051        );
1052    }
1053
1054    /// While an earlier probe of a path is still blocked, a new one answers
1055    /// `Hung` at once instead of parking another thread on the same volume.
1056    #[test]
1057    fn a_path_still_being_probed_is_hung_without_a_second_thread() {
1058        let directory = tempfile::tempdir().unwrap();
1059        let stuck = directory.path().join("stuck-volume");
1060        PROBES_IN_FLIGHT.lock().unwrap().push(stuck.clone());
1061        let started = std::time::Instant::now();
1062        let answer = directory_responds(&stuck, std::time::Duration::from_secs(30));
1063        PROBES_IN_FLIGHT
1064            .lock()
1065            .unwrap()
1066            .retain(|path| *path != stuck);
1067        assert_eq!(answer, Responsiveness::Hung);
1068        assert!(started.elapsed() < std::time::Duration::from_secs(5));
1069    }
1070}