Skip to main content

runner_manager_platform/
runner_root.rs

1// owner: b1-runner-path-platform
2
3//! Where disposable runner attempts are placed, and whether a configured
4//! location can actually hold them.
5//!
6//! `02-target-architecture.md` separates three path concepts that used to share
7//! one directory. [`crate::paths::AppPaths`] owns the first — config, SQLite,
8//! logs, diagnostics and the verified package cache — and nothing here moves
9//! it. This module owns the other two: the **host runner root** under which
10//! ephemeral attempts are created, and the operational check every
11//! **repository persistent root** must also pass.
12//!
13//! ## Two layers, and why the split is load-bearing
14//!
15//! [`LocalAbsolutePath`] is the *pure* layer: it decides whether a string is a
16//! shape the product will persist, with no syscall and no ambient state, so
17//! opening the database never depends on a drive being mounted today. This
18//! module is the *operational* layer named in "Path validation": it runs before
19//! a mutation is committed and before the daemon accepts new allocation, and it
20//! is the only one of the two allowed to ask the filesystem anything.
21//!
22//! ```text
23//! LocalAbsolutePath   absolute? non-root? native? no UNC, device or `..`?
24//! RootPreflight       local volume? writable? a real directory? contained?
25//! ```
26//!
27//! ## What this module will not do
28//!
29//! **It never creates, deletes, or re-permissions anything.** Both writability
30//! probes are pure queries — `access(2)` on Unix, and on Windows a directory
31//! *handle* opened for `FILE_ADD_SUBDIRECTORY`, which runs the real access check
32//! against the real DACL and produces no file. That is deliberate: "validation
33//! performs no deletion or permission mutation" is then a property of the code
34//! rather than a convention, and a preflight that probed by writing a marker
35//! would be a preflight that can leave litter in an operator's directory.
36//! Creating the validated leaf and applying the narrow default-root ACL are
37//! explicit steps their callers take *after* this returns `Ok`, in `b2` and
38//! `c1`.
39//!
40//! ## The Windows default
41//!
42//! D1 is `%SystemDrive%\rman`, "normally `C:\rman`". The drive letter is read
43//! from `GetSystemDirectoryW`, not from `%SystemDrive%`: an environment
44//! variable is writable by whatever launched the process, and this value
45//! decides where a recursive cleanup will later run. Nothing here assumes `C:`
46//! — [`default_runner_root_from`] takes the system directory as an argument, so
47//! the Linux and macOS CI legs test the Windows rule too.
48
49use std::cmp::Ordering;
50use std::ffi::OsString;
51use std::fmt;
52use std::io;
53use std::path::{Path, PathBuf};
54
55use runner_manager_domain::path::{LocalAbsolutePath, LocalPathError, PathPlatform};
56
57use crate::paths::AppPaths;
58
59/// The directory appended to the Windows system drive to form the default host
60/// runner root.
61///
62/// Short on purpose: the feature exists because
63/// `%LOCALAPPDATA%\IvanMurzak\runner-manager\data\runtime\<attempt>` plus a
64/// deep repository checkout exceeds what some build tools tolerate. The owner
65/// selected `rman` over `rm` (ledger, 2026-08-31).
66pub const WINDOWS_RUNNER_ROOT_NAME: &str = "rman";
67
68// ---------------------------------------------------------------------------
69// Who a root belongs to
70// ---------------------------------------------------------------------------
71
72/// Which setting a root came from.
73///
74/// Carried by the preflight so that two roots belonging to the same owner are
75/// never compared with each other — re-validating a repository's *current* root
76/// must not report that it overlaps itself — and so that a failure can name the
77/// command that fixes it.
78#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
79pub enum RootOwner {
80    /// `Host.runner_root_override`, or the platform default standing in for it.
81    Host,
82    /// A repository's persistent workspace root, by `owner/name`.
83    Repository(String),
84}
85
86impl RootOwner {
87    /// The command an operator runs to change this root.
88    ///
89    /// `03-migration-rollout.md` requires a failure to "report the exact
90    /// `host set-runtime-root` remediation command"; the repository half is the
91    /// same requirement for `d1`'s other mutation.
92    #[must_use]
93    pub fn remediation(&self) -> String {
94        match self {
95            RootOwner::Host => "runner-manager host set-runtime-root --path <PATH>".to_string(),
96            RootOwner::Repository(repository) => format!(
97                "runner-manager repo set-workspace {repository} --mode persistent --path <PATH>"
98            ),
99        }
100    }
101}
102
103impl fmt::Display for RootOwner {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        match self {
106            RootOwner::Host => f.write_str("the host runner root"),
107            RootOwner::Repository(repository) => {
108                write!(f, "the persistent workspace root for {repository}")
109            }
110        }
111    }
112}
113
114// ---------------------------------------------------------------------------
115// Overlap
116// ---------------------------------------------------------------------------
117
118/// How two paths sit relative to one another.
119///
120/// Every relation other than [`Overlap::Disjoint`] is refused for a configured
121/// root: equality and descent would put runner material among application data
122/// or another repository's slots, and ancestry would put *that* data inside a
123/// directory this product later removes recursively.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum Overlap {
126    /// Neither path contains the other.
127    Disjoint,
128    /// The two paths are the same directory.
129    Same,
130    /// The first path is below the second.
131    Inside,
132    /// The first path is above the second.
133    Contains,
134}
135
136impl fmt::Display for Overlap {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        f.write_str(match self {
139            Overlap::Disjoint => "is unrelated to",
140            Overlap::Same => "is the same directory as",
141            Overlap::Inside => "is inside",
142            Overlap::Contains => "contains",
143        })
144    }
145}
146
147/// Splits a path into the components an overlap test compares.
148///
149/// Deliberately string-based rather than [`Path`]-based: `Path` only parses the
150/// syntax of the platform it was compiled for, so a `Path`-based comparison
151/// could only be tested on one CI leg, and the Windows drive-prefix case is
152/// exactly the one worth testing everywhere. A Windows path yields its drive as
153/// an ordinary first component (`C:\rman` becomes `["C:", "rman"]`), which is
154/// what keeps `C:\rman` and `D:\rman` disjoint.
155fn components_of(path: &str, platform: PathPlatform) -> Vec<&str> {
156    path.split(|c| platform.is_separator(c))
157        .filter(|component| !component.is_empty() && *component != ".")
158        .collect()
159}
160
161/// Whether two path components name the same directory on `platform`.
162///
163/// Windows compares case-insensitively, and over the whole of Unicode rather
164/// than over ASCII alone: NTFS folds `Ärman` and `ärman` to one directory, so an
165/// ASCII-only comparison would call two roots disjoint that later turn out to be
166/// the same tree, which is the direction that loses data. Unix does not fold: a
167/// Linux volume is case-sensitive, and refusing to distinguish `/srv/Rman` from
168/// `/srv/rman` there would reject two directories an operator legitimately has.
169/// A case-insensitive macOS volume is the residual, and it is the safe direction
170/// only for the containment test, so it is stated rather than hidden.
171fn same_component(left: &str, right: &str, platform: PathPlatform) -> bool {
172    match platform {
173        PathPlatform::Windows => left
174            .chars()
175            .flat_map(char::to_lowercase)
176            .eq(right.chars().flat_map(char::to_lowercase)),
177        PathPlatform::Unix => left == right,
178    }
179}
180
181/// How `candidate` sits relative to `other`.
182fn overlap_of(candidate: &str, other: &str, platform: PathPlatform) -> Overlap {
183    let left = components_of(candidate, platform);
184    let right = components_of(other, platform);
185    let shared = left
186        .iter()
187        .zip(right.iter())
188        .take_while(|(l, r)| same_component(l, r, platform))
189        .count();
190    if shared < left.len().min(right.len()) {
191        return Overlap::Disjoint;
192    }
193    match left.len().cmp(&right.len()) {
194        Ordering::Equal => Overlap::Same,
195        Ordering::Greater => Overlap::Inside,
196        Ordering::Less => Overlap::Contains,
197    }
198}
199
200// ---------------------------------------------------------------------------
201// Errors
202// ---------------------------------------------------------------------------
203
204/// Why a runner root cannot be resolved, or cannot be used.
205///
206/// One type for every caller: CLI, TUI, daemon startup and restart recovery all
207/// reach the same decision, and `02-target-architecture.md` requires them to
208/// reach it with the same messages. No variant may ever be handed a token or a
209/// JIT configuration — these are paths, and they are printed verbatim.
210#[derive(Debug, thiserror::Error)]
211pub enum RunnerRootError {
212    #[error(
213        "the operating system did not report a system directory, so the default runner \
214         root <system-drive>\\{WINDOWS_RUNNER_ROOT_NAME} cannot be resolved: {source}. \
215         Configure one explicitly with `{}`.",
216        RootOwner::Host.remediation()
217    )]
218    SystemDirectoryUnavailable {
219        #[source]
220        source: io::Error,
221    },
222
223    #[error(
224        "the system directory {got:?} is not a usable volume for the default runner \
225         root: {source}. Configure one explicitly with `{}`.",
226        RootOwner::Host.remediation()
227    )]
228    SystemDirectoryUnusable {
229        got: String,
230        #[source]
231        source: LocalPathError,
232    },
233
234    #[error(
235        "the application runtime directory {} cannot be used as the default runner \
236         root: {source}",
237        got.display()
238    )]
239    ApplicationRuntimeDirectoryUnusable {
240        got: PathBuf,
241        #[source]
242        source: LocalPathError,
243    },
244
245    #[error(
246        "{} cannot be represented as text, and a runner root is stored, printed and \
247         compared as text",
248        got.display()
249    )]
250    NonUnicode { got: PathBuf },
251
252    #[error(
253        "{got:?} is written in {platform} path syntax, but this host uses {}; a row \
254         written on another operating system is corrupt state here rather than a \
255         usable root",
256        PathPlatform::NATIVE
257    )]
258    ForeignPlatform { got: String, platform: PathPlatform },
259
260    #[error("cannot inspect {}: {source}", path.display())]
261    Inspect {
262        path: PathBuf,
263        #[source]
264        source: io::Error,
265    },
266
267    #[error(
268        "{} already exists and is not a directory; a runner root is a directory that \
269         attempt directories are created inside",
270        path.display()
271    )]
272    ExistingFile { path: PathBuf },
273
274    #[error(
275        "{} is a symbolic link, junction or other reparse point. A runner root is the \
276         base of a recursive cleanup, so it must be the real directory rather than a \
277         name that can be repointed at one; configure the target directly.",
278        path.display()
279    )]
280    Symlinked { path: PathBuf },
281
282    #[error(
283        "{} cannot be created because more than its last component is missing; the \
284         deepest directory that does exist is {}. Create the intermediate directories \
285         first, or configure a path one level below an existing directory.",
286        path.display(),
287        deepest_existing.display()
288    )]
289    MissingParents {
290        path: PathBuf,
291        deepest_existing: PathBuf,
292    },
293
294    #[error(
295        "{} exists but is not a directory, so nothing can be created inside it",
296        parent.display()
297    )]
298    ParentIsNotADirectory { parent: PathBuf },
299
300    #[error(
301        "{} exists but this account may not create entries in it. Grant this account \
302         write access, or configure a directory it owns with `{remediation}`.",
303        path.display()
304    )]
305    NotWritable { path: PathBuf, remediation: String },
306
307    #[error(
308        "the runner root {} cannot be used: this process runs as the superuser, so file \
309         permissions are not what refused {}, and that directory is on a volume macOS \
310         withholds through its privacy controls. Grant Full Disk Access to the program \
311         that runs the service -- System Settings > Privacy & Security > Full Disk \
312         Access -- and start the service again, or configure a directory on the startup \
313         disk with `{remediation}`. Note that the grant follows the binary and not the \
314         path: an upgrade that replaces the service binary revokes it, and it has to be \
315         granted again to the new one.",
316        requested.display(),
317        refused.display()
318    )]
319    DeniedByPrivacyPolicy {
320        /// The root the operator asked for, which may not exist yet.
321        requested: PathBuf,
322        /// The deepest existing directory, which is the one that refused.
323        refused: PathBuf,
324        remediation: String,
325    },
326
327    #[error(
328        "{} does not exist yet and this account may not create it: its parent {} \
329         refuses. Grant this account write access to that directory, or configure a \
330         directory it owns with `{remediation}`.",
331        leaf.display(),
332        parent.display()
333    )]
334    ParentNotWritable {
335        parent: PathBuf,
336        leaf: PathBuf,
337        remediation: String,
338    },
339
340    #[error(
341        "{} is on {filesystem}. Runner correctness and restart recovery may not depend \
342         on a remote share that can disappear or change identity while a job runs \
343         (D10); configure a directory on a local volume.",
344        path.display()
345    )]
346    RemoteFilesystem { path: PathBuf, filesystem: String },
347
348    #[error(
349        "this host cannot prove that {} is on a local filesystem (it reported \
350         {filesystem}). A runner root is accepted only when locality is provable, so \
351         this fails closed; configure a directory on a local volume.",
352        path.display()
353    )]
354    UnprovableFilesystem { path: PathBuf, filesystem: String },
355
356    #[error(
357        "{} resolves to {}, which is a filesystem root. A runner root must be a \
358         directory below a root, because everything inside it is removed on cleanup.",
359        path.display(),
360        canonical.display()
361    )]
362    ResolvesToFilesystemRoot { path: PathBuf, canonical: PathBuf },
363
364    #[error(
365        "{} {relation} {other_owner} ({}). {detail}",
366        candidate.display(),
367        other.display()
368    )]
369    Overlaps {
370        candidate: PathBuf,
371        relation: Overlap,
372        other: PathBuf,
373        other_owner: String,
374        detail: &'static str,
375    },
376
377    #[error(
378        "{} is derived from the runner root {} but resolves to {}, which is outside it",
379        child.display(),
380        root.display(),
381        resolved.display()
382    )]
383    Escapes {
384        root: PathBuf,
385        child: PathBuf,
386        resolved: PathBuf,
387    },
388
389    #[error("{source}")]
390    DerivedName {
391        #[source]
392        source: LocalPathError,
393    },
394}
395
396// ---------------------------------------------------------------------------
397// Filesystem identity
398// ---------------------------------------------------------------------------
399
400/// What the host can prove about where a directory lives.
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub enum Locality {
403    /// Proven to be served by this machine from a local device.
404    Local,
405    /// Proven to be a network filesystem.
406    Remote,
407    /// The platform answered, but not with something that proves either.
408    ///
409    /// Treated as a refusal. `02-target-architecture.md`: "A platform that
410    /// cannot prove the configured location is local fails closed."
411    Unprovable,
412}
413
414/// The filesystem an existing directory sits on.
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct FilesystemIdentity {
417    /// Whether it is provably local.
418    pub locality: Locality,
419    /// What the platform called it, for the operator-facing message.
420    pub name: String,
421}
422
423impl FilesystemIdentity {
424    /// A filesystem the platform proved local.
425    #[must_use]
426    pub fn local(name: impl Into<String>) -> Self {
427        Self {
428            locality: Locality::Local,
429            name: name.into(),
430        }
431    }
432
433    /// A filesystem the platform proved remote.
434    #[must_use]
435    pub fn remote(name: impl Into<String>) -> Self {
436        Self {
437            locality: Locality::Remote,
438            name: name.into(),
439        }
440    }
441
442    /// A filesystem the platform could not classify either way.
443    #[must_use]
444    pub fn unprovable(name: impl Into<String>) -> Self {
445        Self {
446            locality: Locality::Unprovable,
447            name: name.into(),
448        }
449    }
450}
451
452/// The two questions the preflight asks the operating system.
453///
454/// A trait rather than two free functions because a network share and a
455/// directory this account may not write are not things a test may create: one
456/// needs a file server and the other needs an account that is not the one
457/// running the suite. `04-security-recovery.md` requires "table-driven
458/// cross-platform validator tests" for exactly those cases, so the seam is part
459/// of the design rather than a testing afterthought. [`HostFilesystem`] is the
460/// real implementation and is what every production caller gets.
461pub trait FilesystemProbe {
462    /// The filesystem `directory` — which exists — sits on.
463    ///
464    /// # Errors
465    /// Whatever the platform reported.
466    fn identify(&self, directory: &Path) -> io::Result<FilesystemIdentity>;
467
468    /// Whether this account may create entries in `directory`, which exists.
469    ///
470    /// Implementations must answer without creating, deleting, or
471    /// re-permissioning anything.
472    ///
473    /// # Errors
474    /// Whatever the platform reported, other than a plain refusal.
475    fn is_writable(&self, directory: &Path) -> io::Result<bool>;
476
477    /// Whether this process runs with an identity that file permissions cannot
478    /// refuse.
479    ///
480    /// Asked only to classify a refusal that has already happened, and it is on
481    /// this trait rather than beside it because it is the same kind of question
482    /// as the other two -- something only the operating system can answer, and
483    /// something a test may not arrange for itself: the suite cannot become
484    /// `root` to prove what `root` is told. The default is the real answer, so
485    /// every existing implementation keeps working unchanged.
486    fn runs_as_superuser(&self) -> bool {
487        sys::runs_as_superuser()
488    }
489
490    /// Whether `directory` is on a volume the platform's privacy layer gates.
491    ///
492    /// Here for the same reason as [`FilesystemProbe::runs_as_superuser`]: the
493    /// suite cannot mount an external disk to ask. The default is the real
494    /// answer.
495    fn is_on_privacy_gated_volume(&self, directory: &Path) -> bool {
496        sys::is_on_privacy_gated_volume(directory)
497    }
498
499    /// Whether `directory` is on a filesystem mounted read-only.
500    ///
501    /// Asked because [`FilesystemProbe::is_writable`] cannot answer it: it
502    /// collapses `EACCES`, `EPERM` and `EROFS` into one `false`, so by the time
503    /// a refusal is classified the difference between "this account may not"
504    /// and "nobody may, the volume is read-only" is gone. A read-only mount
505    /// refuses a superuser for an entirely ordinary reason, and a read-only
506    /// disk image mounts under `/Volumes` like any other -- so without this the
507    /// privacy branch below would blame consent for `EROFS`.
508    fn is_read_only(&self, directory: &Path) -> bool {
509        sys::is_read_only(directory)
510    }
511}
512
513/// The real operating system.
514#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
515pub struct HostFilesystem;
516
517impl FilesystemProbe for HostFilesystem {
518    fn identify(&self, directory: &Path) -> io::Result<FilesystemIdentity> {
519        sys::identify(directory)
520    }
521
522    fn is_writable(&self, directory: &Path) -> io::Result<bool> {
523        sys::is_writable(directory)
524    }
525}
526
527/// The default probe, borrowed by [`RootPreflight::new`].
528static HOST_FILESYSTEM: HostFilesystem = HostFilesystem;
529
530impl RunnerRootError {
531    /// Which refusal this is, as a closed-vocabulary token.
532    ///
533    /// The agent's log sink redacts by field name and then by value shape
534    /// (`crate::logging`), and a `RunnerRootError`'s `Display` is mostly
535    /// filesystem paths -- so the sentence an operator needs cannot travel on a
536    /// daemon log line, and the one that tried arrived as `[redacted]`. This is
537    /// the part that *can* travel: a fixed identifier, on an allow-listed field,
538    /// naming which refusal happened. It tells an operator staring at
539    /// `runner_start_failed reason=other` which of a dozen causes they have,
540    /// which is the difference between a diagnosis and a guess.
541    ///
542    /// Every value here must stay a short `snake_case` token, for the reason the
543    /// vocabulary is closed at all: `crate::logging::redact` scrubs anything
544    /// that looks like a path or an opaque run, and a token that acquired a
545    /// slash would be replaced on its way out.
546    #[must_use]
547    pub const fn kind(&self) -> &'static str {
548        match self {
549            Self::SystemDirectoryUnavailable { .. } => "system_directory_unavailable",
550            Self::SystemDirectoryUnusable { .. } => "system_directory_unusable",
551            Self::ApplicationRuntimeDirectoryUnusable { .. } => "runtime_directory_unusable",
552            Self::NonUnicode { .. } => "non_unicode",
553            Self::ForeignPlatform { .. } => "foreign_platform",
554            Self::Inspect { .. } => "not_inspectable",
555            Self::ExistingFile { .. } => "existing_file",
556            Self::Symlinked { .. } => "symlinked",
557            Self::MissingParents { .. } => "missing_parents",
558            Self::ParentIsNotADirectory { .. } => "parent_not_a_directory",
559            Self::NotWritable { .. } => "not_writable",
560            Self::DeniedByPrivacyPolicy { .. } => "denied_by_privacy_policy",
561            Self::ParentNotWritable { .. } => "parent_not_writable",
562            Self::RemoteFilesystem { .. } => "remote_filesystem",
563            Self::UnprovableFilesystem { .. } => "unprovable_filesystem",
564            Self::ResolvesToFilesystemRoot { .. } => "resolves_to_filesystem_root",
565            Self::Overlaps { .. } => "overlaps_application_data",
566            Self::Escapes { .. } => "escapes_root",
567            Self::DerivedName { .. } => "underivable_name",
568        }
569    }
570}
571
572/// Whether `path` sits on a volume macOS withholds from a background service.
573///
574/// The question a *configuration* command asks, and deliberately not one
575/// [`RootPreflight`] asks: a second volume is a perfectly good runner root, and
576/// for the account running the command it usually works on the spot. It is the
577/// **service** that may not be able to reach it -- macOS gates these volumes
578/// behind privacy consent that a `launchd` daemon cannot prompt for -- so this
579/// exists to warn the operator at the moment they configure such a path, rather
580/// than leaving the daemon to fail silently once per poll afterwards.
581///
582/// False on every other platform, and false when the filesystem cannot answer:
583/// this drives a warning, and a warning that fires on a failed syscall is noise.
584#[must_use]
585pub fn is_on_privacy_gated_volume(path: &Path) -> bool {
586    sys::is_on_privacy_gated_volume(path)
587}
588
589// ---------------------------------------------------------------------------
590// The platform default
591// ---------------------------------------------------------------------------
592
593/// Where a platform default comes from.
594///
595/// The seam that keeps `C:` out of this crate: the Windows rule is a function
596/// of the system directory the operating system reported, so every CI leg can
597/// drive it with `E:\Windows\system32` and assert `E:\rman`.
598#[derive(Debug, Clone, Copy, PartialEq, Eq)]
599pub enum PlatformDefault<'a> {
600    /// Windows: whatever `GetSystemDirectoryW` returned.
601    WindowsSystemDirectory(&'a str),
602    /// macOS and Linux: the existing [`AppPaths::runtime_dir`], unchanged.
603    ApplicationRuntimeDirectory(&'a Path),
604}
605
606/// Resolves a platform default from an explicit source.
607///
608/// # Errors
609/// [`RunnerRootError::SystemDirectoryUnusable`] when the reported system
610/// directory is not an ordinary drive path;
611/// [`RunnerRootError::ApplicationRuntimeDirectoryUnusable`] or
612/// [`RunnerRootError::NonUnicode`] when the application runtime directory is
613/// not a storable local path.
614pub fn default_runner_root_from(
615    source: PlatformDefault<'_>,
616) -> Result<LocalAbsolutePath, RunnerRootError> {
617    match source {
618        PlatformDefault::WindowsSystemDirectory(raw) => {
619            let unusable = |source| RunnerRootError::SystemDirectoryUnusable {
620                got: raw.to_string(),
621                source,
622            };
623            // Parsed rather than pattern-matched by hand: this holds the value
624            // the operating system reported to the same rules an operator's own
625            // input is held to — no UNC, no device namespace, no drive-relative
626            // form — and renders the root as exactly `X:\` with the drive letter
627            // upper-cased, which is what makes the slice below total.
628            let system =
629                LocalAbsolutePath::parse_for(raw, PathPlatform::Windows).map_err(unusable)?;
630            let volume: String = system.as_str().chars().take(3).collect();
631            LocalAbsolutePath::parse_for(
632                format!("{volume}{WINDOWS_RUNNER_ROOT_NAME}"),
633                PathPlatform::Windows,
634            )
635            .map_err(unusable)
636        }
637        PlatformDefault::ApplicationRuntimeDirectory(path) => {
638            let text = path.to_str().ok_or_else(|| RunnerRootError::NonUnicode {
639                got: path.to_path_buf(),
640            })?;
641            LocalAbsolutePath::new(text).map_err(|source| {
642                RunnerRootError::ApplicationRuntimeDirectoryUnusable {
643                    got: path.to_path_buf(),
644                    source,
645                }
646            })
647        }
648    }
649}
650
651/// The platform default host runner root for this machine.
652///
653/// Windows resolves `<system-drive>\rman` from `GetSystemDirectoryW`. The
654/// `app_paths` argument is unused on this platform, and is part of the
655/// signature only so that callers are not written twice: application data does
656/// not move, and the runner root is not derived from it here.
657///
658/// # Errors
659/// [`RunnerRootError::SystemDirectoryUnavailable`] or
660/// [`RunnerRootError::SystemDirectoryUnusable`].
661#[cfg(windows)]
662pub fn default_runner_root(app_paths: &AppPaths) -> Result<LocalAbsolutePath, RunnerRootError> {
663    let _ = app_paths;
664    let system = sys::system_directory()
665        .map_err(|source| RunnerRootError::SystemDirectoryUnavailable { source })?;
666    default_runner_root_from(PlatformDefault::WindowsSystemDirectory(&system))
667}
668
669/// The Windows system directory, as the kernel reports it. Shared with
670/// `host_fitness`, which starts `powershell.exe` from it.
671#[cfg(windows)]
672pub(crate) use sys::system_directory as windows_system_directory;
673
674/// The platform default host runner root for this machine.
675///
676/// macOS and Linux keep the directory attempts have always used —
677/// [`AppPaths::runtime_dir`] — byte for byte. Only Windows had a path-length
678/// problem, and relocating the other two would move live workspaces for no
679/// reason (`02-target-architecture.md`, "Platform defaults").
680///
681/// # Errors
682/// [`RunnerRootError::ApplicationRuntimeDirectoryUnusable`] or
683/// [`RunnerRootError::NonUnicode`] when the resolved application runtime
684/// directory is not a storable local path, which for a discovered layout means
685/// the account's own data directory is unusable.
686#[cfg(not(windows))]
687pub fn default_runner_root(app_paths: &AppPaths) -> Result<LocalAbsolutePath, RunnerRootError> {
688    default_runner_root_from(PlatformDefault::ApplicationRuntimeDirectory(
689        app_paths.runtime_dir(),
690    ))
691}
692
693// ---------------------------------------------------------------------------
694// Canonical projection
695// ---------------------------------------------------------------------------
696
697/// A path resolved as far as the filesystem can resolve it.
698///
699/// `02-target-architecture.md` requires "a stable canonical path for any
700/// existing component", and a runner root is routinely configured before it is
701/// created. So the deepest *existing* ancestor is canonicalised — which
702/// resolves every symlink, junction and `8.3` alias on the way to it — and the
703/// components that do not exist yet are appended lexically. That is the value
704/// every overlap, containment and locality decision below is taken against,
705/// which is what makes a symlinked parent unable to smuggle a root into the
706/// application-data tree or onto a network share.
707#[derive(Debug)]
708struct Projection {
709    /// The deepest ancestor that exists, canonicalised.
710    anchor: PathBuf,
711    /// The deepest ancestor that exists, as written.
712    anchor_as_written: PathBuf,
713    /// `anchor` with the missing components appended.
714    canonical: PathBuf,
715    /// How many components do not exist yet.
716    ///
717    /// A count rather than the names, because that is the whole question: the
718    /// leaf is there (`0`), is the one directory the caller may create (`1`),
719    /// or is too deep to create. The names are consumed building `canonical`
720    /// and are the *as-written* spellings, so reading them afterwards would
721    /// mean reasoning about a path that has not been resolved.
722    missing: usize,
723}
724
725/// Whether an error means "this path is not there", as opposed to "this path
726/// could not be inspected".
727///
728/// `NotADirectory` belongs here: `/srv/notes.txt/rman` does not exist, and the
729/// operating system says so by complaining about the component that is a file.
730/// Treating it as an inspection failure would report a confusing errno instead
731/// of the actionable [`RunnerRootError::ParentIsNotADirectory`] the walk
732/// reaches one step later.
733fn means_absent(error: &io::Error) -> bool {
734    matches!(
735        error.kind(),
736        io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
737    )
738}
739
740/// Walks up from `path` to the deepest ancestor that exists and canonicalises
741/// it.
742fn project(path: &Path) -> Result<Projection, RunnerRootError> {
743    let mut missing: Vec<OsString> = Vec::new();
744    let mut cursor = path.to_path_buf();
745    loop {
746        match std::fs::symlink_metadata(&cursor) {
747            Ok(_) => break,
748            Err(error) if means_absent(&error) => {
749                let name = cursor.file_name().map(OsString::from);
750                let parent = cursor.parent().map(Path::to_path_buf);
751                let (Some(name), Some(parent)) = (name, parent) else {
752                    // A filesystem root that does not exist. Nothing above it
753                    // can be inspected, so there is no deeper diagnosis to give.
754                    return Err(RunnerRootError::Inspect {
755                        path: cursor,
756                        source: error,
757                    });
758                };
759                missing.push(name);
760                cursor = parent;
761            }
762            Err(source) => {
763                return Err(RunnerRootError::Inspect {
764                    path: cursor,
765                    source,
766                });
767            }
768        }
769    }
770
771    let anchor = std::fs::canonicalize(&cursor)
772        .map(|canonical| plain(&canonical))
773        .map_err(|source| RunnerRootError::Inspect {
774            path: cursor.clone(),
775            source,
776        })?;
777
778    let mut canonical = anchor.clone();
779    for component in missing.iter().rev() {
780        canonical.push(component);
781    }
782    Ok(Projection {
783        anchor,
784        anchor_as_written: cursor,
785        canonical,
786        missing: missing.len(),
787    })
788}
789
790/// Removes the extended-length prefix `std::fs::canonicalize` adds on Windows.
791///
792/// `\\?\C:\rman` and `C:\rman` are the same directory, but only one of them
793/// compares equal to a configured path or prints as something an operator
794/// recognises. `\\?\UNC\server\share` becomes `\\server\share`, which the
795/// preflight then refuses as the network path it is.
796#[cfg(windows)]
797fn plain(path: &Path) -> PathBuf {
798    let text = path.to_string_lossy();
799    if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
800        return PathBuf::from(format!(r"\\{rest}"));
801    }
802    if let Some(rest) = text.strip_prefix(r"\\?\") {
803        return PathBuf::from(rest);
804    }
805    path.to_path_buf()
806}
807
808/// Unix `canonicalize` returns an ordinary absolute path already.
809#[cfg(not(windows))]
810fn plain(path: &Path) -> PathBuf {
811    path.to_path_buf()
812}
813
814/// The canonical projection of `path` as text, or `None` when the filesystem
815/// cannot answer.
816///
817/// Used only for the *other* paths a candidate is compared against. A protected
818/// application-data directory that cannot be canonicalised — because the
819/// account has no such directory yet — is still compared lexically, which is the
820/// check that already ran; dropping the canonical half of one comparison is
821/// therefore a lost second opinion rather than a lost rule.
822fn canonical_text(path: &Path) -> Option<String> {
823    project(path)
824        .ok()
825        .map(|projection| projection.canonical.to_string_lossy().into_owned())
826}
827
828/// Whether a path names a filesystem root: `/`, `C:\`, and nothing else.
829fn is_filesystem_root(path: &Path) -> bool {
830    path.parent().is_none()
831}
832
833// ---------------------------------------------------------------------------
834// Preflight
835// ---------------------------------------------------------------------------
836
837/// A root that passed the operational preflight.
838///
839/// Holding one is the evidence a caller needs before it creates a directory or
840/// accepts new allocation. It deliberately says whether the directory exists
841/// rather than creating it: `02-target-architecture.md` keeps "directory
842/// creation and the narrowly scoped default-root ACL operation" as explicit
843/// application steps after validation passes.
844#[derive(Debug, Clone, PartialEq, Eq)]
845pub struct PreflightedRoot {
846    root: LocalAbsolutePath,
847    canonical: PathBuf,
848    exists: bool,
849    filesystem: FilesystemIdentity,
850}
851
852impl PreflightedRoot {
853    /// The configured value, exactly as it is stored and displayed.
854    #[must_use]
855    pub const fn root(&self) -> &LocalAbsolutePath {
856        &self.root
857    }
858
859    /// The same directory with every existing component resolved.
860    #[must_use]
861    pub fn canonical(&self) -> &Path {
862        &self.canonical
863    }
864
865    /// Whether the directory is already there.
866    #[must_use]
867    pub const fn exists(&self) -> bool {
868        self.exists
869    }
870
871    /// The single directory the caller must create, or `None` when it exists.
872    #[must_use]
873    pub fn leaf_to_create(&self) -> Option<&Path> {
874        (!self.exists).then(|| self.root.as_path())
875    }
876
877    /// What the host proved about the volume this root sits on.
878    #[must_use]
879    pub const fn filesystem(&self) -> &FilesystemIdentity {
880        &self.filesystem
881    }
882}
883
884/// The operational check a configured or default runner root must pass.
885///
886/// Built once per decision with the application-data layout it must not
887/// collide with, plus every *other* configured root on this host, and then
888/// asked about one candidate at a time.
889///
890/// ```no_run
891/// # use runner_manager_platform::paths::AppPaths;
892/// # use runner_manager_platform::runner_root::{RootOwner, RootPreflight, default_runner_root};
893/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
894/// let paths = AppPaths::discover()?;
895/// let root = default_runner_root(&paths)?;
896/// let checked = RootPreflight::new(&paths).check(&RootOwner::Host, &root)?;
897/// if let Some(leaf) = checked.leaf_to_create() {
898///     // Creation is the caller's explicit step, never the preflight's.
899///     std::fs::create_dir(leaf)?;
900/// }
901/// # Ok(())
902/// # }
903/// ```
904pub struct RootPreflight<'a> {
905    app_paths: &'a AppPaths,
906    others: Vec<(RootOwner, LocalAbsolutePath)>,
907    probe: &'a dyn FilesystemProbe,
908}
909
910impl fmt::Debug for RootPreflight<'_> {
911    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
912        f.debug_struct("RootPreflight")
913            .field("app_paths", &self.app_paths)
914            .field("others", &self.others)
915            .finish_non_exhaustive()
916    }
917}
918
919impl<'a> RootPreflight<'a> {
920    /// A preflight that asks the real operating system.
921    #[must_use]
922    pub fn new(app_paths: &'a AppPaths) -> Self {
923        Self::with_probe(app_paths, &HOST_FILESYSTEM)
924    }
925
926    /// A preflight that asks `probe` instead.
927    #[must_use]
928    pub fn with_probe(app_paths: &'a AppPaths, probe: &'a dyn FilesystemProbe) -> Self {
929        Self {
930            app_paths,
931            others: Vec::new(),
932            probe,
933        }
934    }
935
936    /// Registers another configured root the candidate must not overlap.
937    ///
938    /// Pass the host runner root when checking a repository's, every other
939    /// repository's root when checking one repository's, and every repository's
940    /// root when checking the host's. A root registered under the same owner as
941    /// the candidate is skipped, so re-validating a setting that is already
942    /// stored does not report it as overlapping itself.
943    #[must_use]
944    pub fn against(mut self, owner: RootOwner, root: LocalAbsolutePath) -> Self {
945        self.others.push((owner, root));
946        self
947    }
948
949    /// The application-data directories a configured root may not collide with.
950    ///
951    /// `runtime/` is deliberately absent. It is not application data in the
952    /// sense this rule protects — it is where runner attempts have always been
953    /// created, and on macOS and Linux it *is* the platform default runner root.
954    /// The three that remain hold the SQLite database, the attempt journal, the
955    /// package cache and the diagnostics, which is the list
956    /// `02-target-architecture.md` gives.
957    fn protected(&self) -> [(&'static str, &Path); 3] {
958        [
959            (
960                "the application configuration directory",
961                self.app_paths.config_dir(),
962            ),
963            (
964                "the application state directory",
965                self.app_paths.state_dir(),
966            ),
967            ("the application log directory", self.app_paths.logs_dir()),
968        ]
969    }
970
971    /// Refuses a candidate that collides with application data or another root.
972    ///
973    /// Run twice: once lexically, before the filesystem is touched at all, and
974    /// once against canonical projections, which is what catches a symlink that
975    /// points somewhere it should not.
976    fn reject_overlap(
977        &self,
978        owner: &RootOwner,
979        candidate: &str,
980        canonical: bool,
981    ) -> Result<(), RunnerRootError> {
982        let native = PathPlatform::NATIVE;
983        let text_of = |path: &Path| -> Option<String> {
984            if canonical {
985                canonical_text(path)
986            } else {
987                Some(path.to_string_lossy().into_owned())
988            }
989        };
990
991        // On macOS every application-data directory is a child of one
992        // `Application Support` directory, so `runtime/` — the platform default
993        // runner root — is *inside* `config/`. That nesting is the product's own
994        // layout rather than an operator mistake, so descent into a protected
995        // directory is permitted for exactly the paths at or below `runtime/`.
996        // Equality with a protected directory, and ancestry over one, stay
997        // refused everywhere.
998        //
999        // Asked lazily, because only a descent reaches it. On the canonical
1000        // pass `text_of` is a full ancestor walk plus `canonicalize`, so
1001        // computing this up front would make every *accepted* root pay a
1002        // syscall for an answer no branch ever reads.
1003        let inside_runtime = || {
1004            text_of(self.app_paths.runtime_dir()).is_some_and(|runtime| {
1005                matches!(
1006                    overlap_of(candidate, &runtime, native),
1007                    Overlap::Same | Overlap::Inside
1008                )
1009            })
1010        };
1011
1012        for (label, path) in self.protected() {
1013            let Some(other) = text_of(path) else {
1014                continue;
1015            };
1016            let relation = overlap_of(candidate, &other, native);
1017            if relation == Overlap::Disjoint || (relation == Overlap::Inside && inside_runtime()) {
1018                continue;
1019            }
1020            return Err(RunnerRootError::Overlaps {
1021                candidate: PathBuf::from(candidate),
1022                relation,
1023                other: PathBuf::from(other),
1024                other_owner: label.to_string(),
1025                detail: "Runner workspaces are removed recursively and application data must \
1026                     survive that; configure a directory outside the application data tree.",
1027            });
1028        }
1029
1030        for (other_owner, root) in &self.others {
1031            if other_owner == owner {
1032                continue;
1033            }
1034            let Some(other) = text_of(root.as_path()) else {
1035                continue;
1036            };
1037            let relation = overlap_of(candidate, &other, native);
1038            if relation == Overlap::Disjoint {
1039                continue;
1040            }
1041            return Err(RunnerRootError::Overlaps {
1042                candidate: PathBuf::from(candidate),
1043                relation,
1044                other: PathBuf::from(other),
1045                other_owner: other_owner.to_string(),
1046                detail: "Two runner roots that contain one another can delete each other's \
1047                     workspaces; configure directories that do not overlap.",
1048            });
1049        }
1050        Ok(())
1051    }
1052
1053    /// Decides whether `root` can hold runner workspaces on this host.
1054    ///
1055    /// Nothing is created, removed or re-permissioned. On success the caller
1056    /// learns whether the directory already exists and, if not, the single leaf
1057    /// it must create.
1058    ///
1059    /// # Errors
1060    /// Any [`RunnerRootError`] other than the three that belong to default
1061    /// resolution.
1062    pub fn check(
1063        &self,
1064        owner: &RootOwner,
1065        root: &LocalAbsolutePath,
1066    ) -> Result<PreflightedRoot, RunnerRootError> {
1067        if root.platform() != PathPlatform::NATIVE {
1068            return Err(RunnerRootError::ForeignPlatform {
1069                got: root.as_str().to_string(),
1070                platform: root.platform(),
1071            });
1072        }
1073        let candidate = root.as_path();
1074
1075        // Lexical first: a candidate that collides on its face is refused
1076        // before the filesystem is asked anything at all, so a configured
1077        // overlap is reported identically on a host where the directory does
1078        // not exist yet.
1079        self.reject_overlap(owner, root.as_str(), false)?;
1080
1081        // What the candidate itself is, before anything is resolved. This runs
1082        // ahead of the projection because a link whose target is gone cannot be
1083        // canonicalised: asking `project` first would report a bare "cannot
1084        // inspect … not found" for a name the operator can plainly see, instead
1085        // of the `Symlinked` refusal that says what to do about it.
1086        match std::fs::symlink_metadata(candidate) {
1087            Ok(metadata) if metadata.file_type().is_symlink() => {
1088                return Err(RunnerRootError::Symlinked {
1089                    path: candidate.to_path_buf(),
1090                });
1091            }
1092            Ok(metadata) if !metadata.is_dir() => {
1093                return Err(RunnerRootError::ExistingFile {
1094                    path: candidate.to_path_buf(),
1095                });
1096            }
1097            Ok(_) => {}
1098            // Absence is the ordinary case: the leaf is created after this
1099            // returns. Anything else is a genuine inspection failure.
1100            Err(error) if means_absent(&error) => {}
1101            Err(source) => {
1102                return Err(RunnerRootError::Inspect {
1103                    path: candidate.to_path_buf(),
1104                    source,
1105                });
1106            }
1107        }
1108
1109        let projection = project(candidate)?;
1110        match projection.missing {
1111            0 => {}
1112            1 => {
1113                if !projection.anchor.is_dir() {
1114                    return Err(RunnerRootError::ParentIsNotADirectory {
1115                        parent: projection.anchor_as_written.clone(),
1116                    });
1117                }
1118            }
1119            _ => {
1120                return Err(RunnerRootError::MissingParents {
1121                    path: candidate.to_path_buf(),
1122                    deepest_existing: projection.anchor_as_written.clone(),
1123                });
1124            }
1125        }
1126
1127        if is_filesystem_root(&projection.canonical) {
1128            return Err(RunnerRootError::ResolvesToFilesystemRoot {
1129                path: candidate.to_path_buf(),
1130                canonical: projection.canonical.clone(),
1131            });
1132        }
1133
1134        let filesystem =
1135            self.probe
1136                .identify(&projection.anchor)
1137                .map_err(|source| RunnerRootError::Inspect {
1138                    path: projection.anchor.clone(),
1139                    source,
1140                })?;
1141        match filesystem.locality {
1142            Locality::Local => {}
1143            Locality::Remote => {
1144                return Err(RunnerRootError::RemoteFilesystem {
1145                    path: projection.canonical.clone(),
1146                    filesystem: filesystem.name,
1147                });
1148            }
1149            Locality::Unprovable => {
1150                return Err(RunnerRootError::UnprovableFilesystem {
1151                    path: projection.canonical.clone(),
1152                    filesystem: filesystem.name,
1153                });
1154            }
1155        }
1156
1157        let writable = self
1158            .probe
1159            .is_writable(&projection.anchor)
1160            .map_err(|source| RunnerRootError::Inspect {
1161                path: projection.anchor.clone(),
1162                source,
1163            })?;
1164        if !writable {
1165            // A refusal that reached a superuser is not a permission problem,
1166            // because there is no permission a superuser lacks. On macOS, and on
1167            // a volume the privacy layer gates, the remaining explanation is
1168            // that layer: it denies a LaunchDaemon an external or removable
1169            // volume until the program is granted Full Disk Access, and a daemon
1170            // cannot raise the prompt that would say so, so it fails silently
1171            // and forever. Reported apart because the remediation is a different
1172            // one entirely: `NotWritable` sends the operator to `chmod`, which
1173            // cannot fix this.
1174            //
1175            // All three conditions, and the volume one matters most: a superuser
1176            // refused by a read-only mount, a mounted disk image, or a
1177            // SIP-protected directory on the startup disk is refused for an
1178            // ordinary reason, and blaming the privacy layer would send the
1179            // operator to grant an access that changes nothing.
1180            if cfg!(target_os = "macos")
1181                && self.probe.runs_as_superuser()
1182                && self.probe.is_on_privacy_gated_volume(&projection.anchor)
1183                && !self.probe.is_read_only(&projection.anchor)
1184            {
1185                return Err(RunnerRootError::DeniedByPrivacyPolicy {
1186                    // Both paths, because they differ when the leaf does not
1187                    // exist yet and it is the parent that refused -- the
1188                    // distinction the two variants below exist to keep.
1189                    requested: candidate.to_path_buf(),
1190                    refused: projection.anchor_as_written.clone(),
1191                    remediation: owner.remediation(),
1192                });
1193            }
1194            // `05-user-workflows.md` requires an unwritable parent to "show
1195            // `host set-runtime-root` or `repo set-workspace` remediation",
1196            // which is what the owner was carried here for.
1197            return Err(if projection.missing == 0 {
1198                RunnerRootError::NotWritable {
1199                    path: projection.anchor_as_written.clone(),
1200                    remediation: owner.remediation(),
1201                }
1202            } else {
1203                RunnerRootError::ParentNotWritable {
1204                    parent: projection.anchor_as_written.clone(),
1205                    leaf: candidate.to_path_buf(),
1206                    remediation: owner.remediation(),
1207                }
1208            });
1209        }
1210
1211        // Second opinion, against what the paths actually resolve to. A root
1212        // that is lexically unrelated to `state/` but reaches it through a
1213        // symlinked parent is refused here.
1214        self.reject_overlap(owner, &projection.canonical.to_string_lossy(), true)?;
1215
1216        Ok(PreflightedRoot {
1217            root: root.clone(),
1218            canonical: projection.canonical,
1219            exists: projection.missing == 0,
1220            filesystem,
1221        })
1222    }
1223}
1224
1225// ---------------------------------------------------------------------------
1226// Derived paths
1227// ---------------------------------------------------------------------------
1228
1229/// The path of one attempt directory or persistent slot below `root`.
1230///
1231/// Containment is by construction: `name` must be a single component, so
1232/// `<root>/<12-char-attempt>` and `<root>/sN` cannot escape however the caller
1233/// spells them. That is the lexical half of the requirement; the canonical half
1234/// is [`verify_containment`], which is what a junction planted inside the root
1235/// has to get past.
1236///
1237/// # Errors
1238/// [`RunnerRootError::DerivedName`] when `name` is not one component, or is not
1239/// a name this platform can store.
1240pub fn derive_child(
1241    root: &LocalAbsolutePath,
1242    name: &str,
1243) -> Result<LocalAbsolutePath, RunnerRootError> {
1244    root.join_child(name)
1245        .map_err(|source| RunnerRootError::DerivedName { source })
1246}
1247
1248/// Proves that `child` is below `root`, lexically and after resolution.
1249///
1250/// `04-security-recovery.md` requires cleanup to "verify canonical containment
1251/// without following a link outside the root" before it removes anything, and
1252/// allocation to "build `<persistent-root>/sN` and validate containment" before
1253/// it journals. Both are this function.
1254///
1255/// # Errors
1256/// [`RunnerRootError::ForeignPlatform`] when either value is written in the
1257/// other operating system's path syntax, which the rest of this function would
1258/// otherwise judge against this host's filesystem;
1259/// [`RunnerRootError::Escapes`] when `child` is not strictly inside `root`
1260/// either lexically or once every existing component is resolved;
1261/// [`RunnerRootError::Inspect`] when the filesystem cannot answer.
1262pub fn verify_containment(
1263    root: &LocalAbsolutePath,
1264    child: &LocalAbsolutePath,
1265) -> Result<(), RunnerRootError> {
1266    // The same guard [`RootPreflight::check`] opens with. Both halves below ask
1267    // the *native* filesystem what these strings resolve to, so a row written on
1268    // another operating system is corrupt state here rather than a path whose
1269    // containment can be argued about.
1270    for value in [root, child] {
1271        if value.platform() != PathPlatform::NATIVE {
1272            return Err(RunnerRootError::ForeignPlatform {
1273                got: value.as_str().to_string(),
1274                platform: value.platform(),
1275            });
1276        }
1277    }
1278    let escapes = |resolved: PathBuf| RunnerRootError::Escapes {
1279        root: root.as_path().to_path_buf(),
1280        child: child.as_path().to_path_buf(),
1281        resolved,
1282    };
1283    if overlap_of(child.as_str(), root.as_str(), root.platform()) != Overlap::Inside {
1284        return Err(escapes(child.as_path().to_path_buf()));
1285    }
1286    let root_projection = project(root.as_path())?;
1287    let child_projection = project(child.as_path())?;
1288    let relation = overlap_of(
1289        &child_projection.canonical.to_string_lossy(),
1290        &root_projection.canonical.to_string_lossy(),
1291        root.platform(),
1292    );
1293    if relation != Overlap::Inside {
1294        return Err(escapes(child_projection.canonical));
1295    }
1296    Ok(())
1297}
1298
1299// ---------------------------------------------------------------------------
1300// Platform adapters
1301// ---------------------------------------------------------------------------
1302//
1303// Both implement the same two-function contract, and the Windows half adds the
1304// system-directory read that has no Unix equivalent:
1305//
1306//   identify(dir)        -> which filesystem `dir` is on, and whether it is local
1307//   is_writable(dir)     -> may this account create entries in `dir`?
1308//   system_directory()   -> Windows only; the source of the default drive letter
1309
1310#[cfg(windows)]
1311mod sys {
1312    use std::io;
1313    use std::os::windows::ffi::OsStrExt;
1314    use std::path::Path;
1315
1316    use windows::Win32::Foundation::{CloseHandle, ERROR_ACCESS_DENIED};
1317    use windows::Win32::Storage::FileSystem::{
1318        CreateFileW, FILE_ADD_SUBDIRECTORY, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE,
1319        FILE_SHARE_READ, FILE_SHARE_WRITE, GetDriveTypeW, GetVolumePathNameW, OPEN_EXISTING,
1320    };
1321    use windows::Win32::System::SystemInformation::GetSystemDirectoryW;
1322    use windows::Win32::System::WindowsProgramming::{
1323        DRIVE_CDROM, DRIVE_FIXED, DRIVE_NO_ROOT_DIR, DRIVE_RAMDISK, DRIVE_REMOTE, DRIVE_REMOVABLE,
1324        DRIVE_UNKNOWN,
1325    };
1326    use windows::core::PCWSTR;
1327
1328    use super::FilesystemIdentity;
1329
1330    /// `HRESULT_FROM_WIN32`, which windows-rs does not re-export as a function.
1331    const fn hresult_from_win32(code: u32) -> i32 {
1332        if code == 0 {
1333            0
1334        } else {
1335            ((code & 0x0000_ffff) | 0x8007_0000) as i32
1336        }
1337    }
1338
1339    /// The `io::Error` a Windows failure really is.
1340    ///
1341    /// `io::Error::from_raw_os_error` expects the Win32 code, not the
1342    /// `HRESULT` windows-rs reports. Handing it `0x8007_0005` produces an error
1343    /// whose `kind()` is `Uncategorized` and whose text ends `(os error
1344    /// -2147024891)`, so the low word is unwrapped again whenever the facility
1345    /// is `FACILITY_WIN32`.
1346    fn io_error(error: &windows::core::Error) -> io::Error {
1347        let code = error.code().0;
1348        #[allow(clippy::cast_sign_loss)]
1349        let unsigned = code as u32;
1350        if unsigned & 0xffff_0000 == 0x8007_0000 {
1351            #[allow(clippy::cast_possible_wrap)]
1352            return io::Error::from_raw_os_error((unsigned & 0x0000_ffff) as i32);
1353        }
1354        io::Error::from_raw_os_error(code)
1355    }
1356
1357    fn to_wide(path: &Path) -> Vec<u16> {
1358        path.as_os_str()
1359            .encode_wide()
1360            .chain(std::iter::once(0))
1361            .collect()
1362    }
1363
1364    /// The Windows system directory, for instance `C:\Windows\system32`.
1365    ///
1366    /// `%SystemDrive%` and `%SystemRoot%` are process environment variables and
1367    /// are writable by whatever started this process; the value read here
1368    /// decides where a recursive cleanup will later run, so it comes from the
1369    /// kernel.
1370    pub(crate) fn system_directory() -> io::Result<String> {
1371        // The system directory is `<drive>\Windows\system32` on every supported
1372        // release, so `MAX_PATH` is already generous; the length is checked
1373        // rather than assumed all the same.
1374        let mut buffer = [0u16; 512];
1375        // SAFETY: `GetSystemDirectoryW` writes at most `buffer.len()` UTF-16
1376        // code units into the slice it is given and reads nothing else. The
1377        // slice is owned by this frame and outlives the call.
1378        let written = unsafe { GetSystemDirectoryW(Some(&mut buffer)) } as usize;
1379        if written == 0 {
1380            return Err(io::Error::last_os_error());
1381        }
1382        if written > buffer.len() {
1383            return Err(io::Error::other(format!(
1384                "the system directory needs {written} UTF-16 code units, which is more \
1385                 than a system path is expected to occupy"
1386            )));
1387        }
1388        String::from_utf16(&buffer[..written]).map_err(io::Error::other)
1389    }
1390
1391    /// The mount point `directory` sits on, NUL-terminated.
1392    ///
1393    /// Asked for rather than assumed to be the drive letter: a volume can be
1394    /// mounted at `C:\mnt\builds`, and the drive type of `C:` says nothing
1395    /// about it.
1396    fn volume_path(directory: &Path) -> io::Result<Vec<u16>> {
1397        let file = to_wide(directory);
1398        let mut buffer = [0u16; 512];
1399        // SAFETY: `file` is NUL-terminated and outlives the call; the output
1400        // slice is owned by this frame and its length is passed by the binding.
1401        unsafe { GetVolumePathNameW(PCWSTR(file.as_ptr()), &mut buffer) }
1402            .map_err(|error| io_error(&error))?;
1403        let length = buffer
1404            .iter()
1405            .position(|unit| *unit == 0)
1406            .unwrap_or(buffer.len());
1407        let mut mount = buffer[..length].to_vec();
1408        mount.push(0);
1409        Ok(mount)
1410    }
1411
1412    pub(super) fn identify(directory: &Path) -> io::Result<FilesystemIdentity> {
1413        let mount = volume_path(directory)?;
1414        // SAFETY: `mount` is NUL-terminated and outlives the call.
1415        let kind = unsafe { GetDriveTypeW(PCWSTR(mount.as_ptr())) };
1416        Ok(match kind {
1417            DRIVE_FIXED => FilesystemIdentity::local("a fixed local volume"),
1418            DRIVE_REMOVABLE => FilesystemIdentity::local("a removable local volume"),
1419            DRIVE_RAMDISK => FilesystemIdentity::local("a RAM disk"),
1420            DRIVE_CDROM => FilesystemIdentity::local("an optical drive"),
1421            DRIVE_REMOTE => FilesystemIdentity::remote("a network drive"),
1422            DRIVE_NO_ROOT_DIR => FilesystemIdentity::unprovable("no mounted volume"),
1423            DRIVE_UNKNOWN => FilesystemIdentity::unprovable("an unknown drive type"),
1424            other => FilesystemIdentity::unprovable(format!("drive type {other}")),
1425        })
1426    }
1427
1428    /// Always false: Windows has no privacy layer that withholds a local volume
1429    /// from a service, so there is nothing for a configuration command to warn
1430    /// about.
1431    pub(super) const fn is_on_privacy_gated_volume(_path: &Path) -> bool {
1432        false
1433    }
1434
1435    /// Never consulted: the only caller gates on macOS first, and Windows has
1436    /// no privacy layer that can refuse an administrator a local volume. It
1437    /// exists so the trait's default method compiles on every target.
1438    pub(super) const fn runs_as_superuser() -> bool {
1439        false
1440    }
1441
1442    /// As [`runs_as_superuser`]: present so the trait's default method
1443    /// compiles, and reached only through a branch Windows never takes.
1444    pub(super) const fn is_read_only(_path: &Path) -> bool {
1445        false
1446    }
1447
1448    /// Whether this account may create entries in `directory`.
1449    ///
1450    /// Opening a *handle* to the directory for `FILE_ADD_SUBDIRECTORY` runs the
1451    /// real access check against the real DACL, including inherited and deny
1452    /// entries, and creates nothing. `_waccess` cannot answer this on Windows:
1453    /// it reports the read-only attribute and not the access-control entries
1454    /// that actually decide.
1455    ///
1456    /// `FILE_ADD_SUBDIRECTORY` alone, and deliberately not
1457    /// `FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY`: `CreateFileW` grants a handle
1458    /// only when *every* requested right is held, and the default DACL of the
1459    /// system drive root grants `Authenticated Users` exactly `AD` — add
1460    /// subdirectory — without `WD`. Asking for both therefore refuses `C:\`,
1461    /// which would make the product's own Windows default `C:\rman`
1462    /// unconfigurable for any process that is not elevated. Only directories
1463    /// are ever created directly in a runner root (`<root>/<attempt>` and
1464    /// `<root>/sN`), so this is also the right question rather than a weaker
1465    /// one; files below them are governed by the DACL those directories
1466    /// inherit.
1467    pub(super) fn is_writable(directory: &Path) -> io::Result<bool> {
1468        let wide = to_wide(directory);
1469        let access = FILE_ADD_SUBDIRECTORY.0;
1470        // SAFETY: `wide` is NUL-terminated and outlives the call. The handle is
1471        // closed on the success path below and nothing else is passed by
1472        // pointer. `FILE_FLAG_BACKUP_SEMANTICS` is what makes `CreateFileW`
1473        // willing to open a directory at all.
1474        let opened = unsafe {
1475            CreateFileW(
1476                PCWSTR(wide.as_ptr()),
1477                access,
1478                FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
1479                None,
1480                OPEN_EXISTING,
1481                FILE_FLAG_BACKUP_SEMANTICS,
1482                None,
1483            )
1484        };
1485        match opened {
1486            Ok(handle) => {
1487                // SAFETY: `handle` was just returned by `CreateFileW` and is
1488                // not used again.
1489                unsafe {
1490                    let _ = CloseHandle(handle);
1491                }
1492                Ok(true)
1493            }
1494            Err(error) if error.code().0 == hresult_from_win32(ERROR_ACCESS_DENIED.0) => Ok(false),
1495            Err(error) => Err(io_error(&error)),
1496        }
1497    }
1498}
1499
1500#[cfg(unix)]
1501mod sys {
1502    use std::ffi::CString;
1503    use std::io;
1504    use std::os::unix::ffi::OsStrExt;
1505    use std::path::Path;
1506
1507    use super::FilesystemIdentity;
1508
1509    fn c_path(path: &Path) -> io::Result<CString> {
1510        CString::new(path.as_os_str().as_bytes()).map_err(|_| {
1511            io::Error::other("a path containing a NUL cannot be given to the operating system")
1512        })
1513    }
1514
1515    /// Whether `path` sits on a volume the privacy layer gates.
1516    ///
1517    /// The test is the **mount point**, not the path as written, so a symlink or
1518    /// a directory deep inside the volume answers the same as its root.
1519    ///
1520    /// `/Volumes/` and nothing else, because that is exactly where macOS mounts
1521    /// the volumes it gates: external and removable disks, disk images, and
1522    /// network shares. The startup disk is deliberately not one path but
1523    /// several -- the read-only system volume is at `/`, everything an operator
1524    /// actually writes is on the Data volume mounted at `/System/Volumes/Data`,
1525    /// and `/Users`, `/tmp` and `/var` all resolve onto it. Asking "is the mount
1526    /// point `/`?" therefore answers *yes, gated* for every ordinary home
1527    /// directory on the machine, which would fire this warning on almost every
1528    /// root anyone configures.
1529    #[cfg(target_os = "macos")]
1530    pub(super) fn is_on_privacy_gated_volume(path: &Path) -> bool {
1531        let Ok(path) = c_path(path) else {
1532            return false;
1533        };
1534        let mut buffer: libc::statfs = unsafe { std::mem::zeroed() };
1535        // SAFETY: `path` is a NUL-terminated C string that outlives the call,
1536        // and `buffer` is a live, correctly sized `statfs` this frame owns.
1537        if unsafe { libc::statfs(path.as_ptr(), &raw mut buffer) } != 0 {
1538            return false;
1539        }
1540        // SAFETY: `statfs` succeeded, so `f_mntonname` holds a NUL-terminated
1541        // mount point inside a buffer this frame owns.
1542        let mount = unsafe { std::ffi::CStr::from_ptr(buffer.f_mntonname.as_ptr()) };
1543        mount.to_bytes().starts_with(b"/Volumes/")
1544    }
1545
1546    /// Always false: Linux has no privacy layer that withholds a mounted volume
1547    /// from a service.
1548    #[cfg(not(target_os = "macos"))]
1549    pub(super) const fn is_on_privacy_gated_volume(_path: &Path) -> bool {
1550        false
1551    }
1552
1553    /// Never consulted: the privacy branch that asks gates on macOS first. It
1554    /// exists so the trait's default method compiles on every target.
1555    #[cfg(not(target_os = "macos"))]
1556    pub(super) const fn is_read_only(_path: &Path) -> bool {
1557        false
1558    }
1559
1560    /// Whether `path` is on a filesystem mounted read-only.
1561    ///
1562    /// `MNT_RDONLY`, which is the mount flag rather than a probe of `path`
1563    /// itself: a writable directory cannot exist on a read-only mount, and the
1564    /// mount is the thing whose remediation differs.
1565    ///
1566    /// macOS-only, and the `cfg` is load-bearing rather than tidiness: `f_flags`
1567    /// and `MNT_RDONLY` are BSD `statfs`, and Linux's `statfs` has neither --
1568    /// it spells the same fact `f_flags`-less, through `statvfs`'s `ST_RDONLY`.
1569    /// Only the macOS branch of the caller asks, so the other targets answer
1570    /// `false` rather than carrying a second implementation nothing reaches.
1571    #[cfg(target_os = "macos")]
1572    pub(super) fn is_read_only(path: &Path) -> bool {
1573        let Ok(path) = c_path(path) else {
1574            return false;
1575        };
1576        let mut buffer: libc::statfs = unsafe { std::mem::zeroed() };
1577        // SAFETY: `path` is a NUL-terminated C string that outlives the call,
1578        // and `buffer` is a live, correctly sized `statfs` this frame owns.
1579        if unsafe { libc::statfs(path.as_ptr(), &raw mut buffer) } != 0 {
1580            return false;
1581        }
1582        buffer.f_flags & u32::try_from(libc::MNT_RDONLY).unwrap_or(0) != 0
1583    }
1584
1585    /// Whether this process runs with an identity that file permissions cannot
1586    /// refuse.
1587    ///
1588    /// The *effective* user, because that is the identity the kernel checks
1589    /// entries against, and it is the one a service manager sets.
1590    pub(super) fn runs_as_superuser() -> bool {
1591        // SAFETY: `geteuid` reads this process's own credentials, takes no
1592        // pointer, and cannot fail.
1593        unsafe { libc::geteuid() == 0 }
1594    }
1595
1596    /// Whether this account may create entries in `directory`.
1597    ///
1598    /// `access(2)` answers exactly the question and mutates nothing.
1599    /// `X_OK` is asked for alongside `W_OK` because a directory that cannot be
1600    /// traversed cannot hold a workspace either, however writable it claims to
1601    /// be. The check is against the real rather than the effective user
1602    /// identity; this program is never installed setuid, so the two agree.
1603    pub(super) fn is_writable(directory: &Path) -> io::Result<bool> {
1604        let path = c_path(directory)?;
1605        // SAFETY: `path` is a NUL-terminated C string that outlives the call,
1606        // and `access` writes nothing through it.
1607        let result = unsafe { libc::access(path.as_ptr(), libc::W_OK | libc::X_OK) };
1608        if result == 0 {
1609            return Ok(true);
1610        }
1611        let error = io::Error::last_os_error();
1612        match error.raw_os_error() {
1613            // A refusal, rather than a failure to ask.
1614            Some(libc::EACCES | libc::EPERM | libc::EROFS) => Ok(false),
1615            _ => Err(error),
1616        }
1617    }
1618
1619    /// Filesystems Linux reports that are served by this kernel from a local
1620    /// device.
1621    ///
1622    /// An allowlist, because the rule is "fails closed": a magic number that is
1623    /// not here is [`super::Locality::Unprovable`] and is refused, which is the
1624    /// safe direction for a value that decides where a recursive cleanup runs.
1625    /// `fuse` is deliberately absent — it is the same magic number for a local
1626    /// overlay and for `sshfs`.
1627    #[cfg(target_os = "linux")]
1628    const LOCAL_MAGICS: &[(u32, &str)] = &[
1629        (0x0000_ef53, "ext2/ext3/ext4"),
1630        (0x9123_683e, "btrfs"),
1631        (0x5846_5342, "xfs"),
1632        (0x0102_1994, "tmpfs"),
1633        (0x794c_7630, "overlayfs"),
1634        (0x2fc1_2fc1, "zfs"),
1635        (0xf2f5_2010, "f2fs"),
1636        (0x0000_4d44, "vfat"),
1637        (0x2011_bab0, "exfat"),
1638        (0x5346_544e, "ntfs"),
1639        (0x8584_58f6, "ramfs"),
1640        (0x0000_9660, "iso9660"),
1641        (0x7371_7368, "squashfs"),
1642        (0x3153_464a, "jfs"),
1643        (0x5265_4973, "reiserfs"),
1644        (0xca45_1a4e, "bcachefs"),
1645        (0x0000_4244, "hfs"),
1646        (0x0000_482b, "hfsplus"),
1647    ];
1648
1649    /// Filesystems Linux reports that are served over a network.
1650    #[cfg(target_os = "linux")]
1651    const REMOTE_MAGICS: &[(u32, &str)] = &[
1652        (0x0000_6969, "nfs"),
1653        (0xff53_4d42, "cifs"),
1654        (0xfe53_4d42, "smb2"),
1655        (0x0000_517b, "smb"),
1656        (0x7375_7245, "coda"),
1657        (0x0000_564c, "ncpfs"),
1658        (0x5346_414f, "afs"),
1659        (0x6b41_4653, "afs"),
1660        (0x0bd0_0bd0, "lustre"),
1661        (0x00c3_6400, "ceph"),
1662        (0x0102_1997, "9p"),
1663        (0x0116_1970, "gfs2"),
1664        (0x7461_636f, "ocfs2"),
1665    ];
1666
1667    #[cfg(target_os = "linux")]
1668    pub(super) fn identify(directory: &Path) -> io::Result<FilesystemIdentity> {
1669        let path = c_path(directory)?;
1670        let mut buffer: libc::statfs = unsafe { std::mem::zeroed() };
1671        // SAFETY: `path` is a NUL-terminated C string that outlives the call,
1672        // and `buffer` is a live, correctly sized `statfs` this frame owns.
1673        let result = unsafe { libc::statfs(path.as_ptr(), &raw mut buffer) };
1674        if result != 0 {
1675            return Err(io::Error::last_os_error());
1676        }
1677        // `f_type` is a signed word on this platform and several magic numbers
1678        // have the high bit set; comparing as `u32` is what makes `cifs` match.
1679        let magic = buffer.f_type as u32;
1680        if let Some((_, name)) = LOCAL_MAGICS.iter().find(|(value, _)| *value == magic) {
1681            return Ok(FilesystemIdentity::local(*name));
1682        }
1683        if let Some((_, name)) = REMOTE_MAGICS.iter().find(|(value, _)| *value == magic) {
1684            return Ok(FilesystemIdentity::remote(*name));
1685        }
1686        Ok(FilesystemIdentity::unprovable(format!(
1687            "filesystem type 0x{magic:08x}"
1688        )))
1689    }
1690
1691    /// macOS answers the question directly: `MNT_LOCAL` is set when the
1692    /// filesystem "is stored locally", so there is no magic-number table to
1693    /// keep current and no unprovable middle case.
1694    #[cfg(target_os = "macos")]
1695    pub(super) fn identify(directory: &Path) -> io::Result<FilesystemIdentity> {
1696        let path = c_path(directory)?;
1697        let mut buffer: libc::statfs = unsafe { std::mem::zeroed() };
1698        // SAFETY: `path` is a NUL-terminated C string that outlives the call,
1699        // and `buffer` is a live, correctly sized `statfs` this frame owns.
1700        let result = unsafe { libc::statfs(path.as_ptr(), &raw mut buffer) };
1701        if result != 0 {
1702            return Err(io::Error::last_os_error());
1703        }
1704        // SAFETY: `f_fstypename` is a NUL-terminated C string the kernel just
1705        // filled in, and the borrow ends before `buffer` does.
1706        let name = unsafe { std::ffi::CStr::from_ptr(buffer.f_fstypename.as_ptr()) }
1707            .to_string_lossy()
1708            .into_owned();
1709        #[allow(clippy::cast_sign_loss)]
1710        let local = buffer.f_flags & (libc::MNT_LOCAL as u32) != 0;
1711        Ok(if local {
1712            FilesystemIdentity::local(name)
1713        } else {
1714            FilesystemIdentity::remote(name)
1715        })
1716    }
1717
1718    /// Every other Unix. `crate::os` already refuses to classify such a host, so
1719    /// this exists to keep the crate compiling rather than to serve one.
1720    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1721    pub(super) fn identify(_directory: &Path) -> io::Result<FilesystemIdentity> {
1722        Ok(FilesystemIdentity::unprovable(
1723            "an operating system this build cannot interrogate",
1724        ))
1725    }
1726}
1727
1728#[cfg(test)]
1729mod tests {
1730    use super::*;
1731
1732    use PathPlatform::{Unix, Windows};
1733
1734    // -- fixtures -----------------------------------------------------------
1735
1736    /// A probe that answers whatever a test needs.
1737    ///
1738    /// A network share and a directory this account may not write cannot be
1739    /// created by a test: one needs a file server, the other needs a second
1740    /// account. Everything else below runs against the real filesystem through
1741    /// [`HostFilesystem`].
1742    #[derive(Debug)]
1743    struct StubFilesystem {
1744        identity: FilesystemIdentity,
1745        writable: bool,
1746        superuser: bool,
1747        gated_volume: bool,
1748        read_only: bool,
1749    }
1750
1751    impl StubFilesystem {
1752        fn saying(identity: FilesystemIdentity) -> Self {
1753            Self {
1754                identity,
1755                writable: true,
1756                superuser: false,
1757                gated_volume: false,
1758                read_only: false,
1759            }
1760        }
1761
1762        fn unwritable() -> Self {
1763            Self {
1764                identity: FilesystemIdentity::local("a test volume"),
1765                writable: false,
1766                superuser: false,
1767                gated_volume: false,
1768                read_only: false,
1769            }
1770        }
1771
1772        /// An unwritable directory on a gated volume, refused to a process
1773        /// nothing can refuse.
1774        ///
1775        /// `cfg`-gated with its only callers: a test call site behind
1776        /// `#[cfg(target_os = "macos")]` does not keep an associated function
1777        /// alive on the other targets, and CI runs `clippy --all-targets -D
1778        /// warnings` on all three.
1779        #[cfg(target_os = "macos")]
1780        fn unwritable_to_the_superuser() -> Self {
1781            Self {
1782                superuser: true,
1783                gated_volume: true,
1784                ..Self::unwritable()
1785            }
1786        }
1787
1788        /// A superuser refused by an ordinary cause on a volume the privacy
1789        /// layer does not gate. `cfg`-gated for the reason above.
1790        #[cfg(target_os = "macos")]
1791        fn unwritable_to_the_superuser_on_the_startup_disk() -> Self {
1792            Self {
1793                superuser: true,
1794                ..Self::unwritable()
1795            }
1796        }
1797
1798        /// A gated volume that is refusing for the most ordinary reason there
1799        /// is: it is mounted read-only.
1800        #[cfg(target_os = "macos")]
1801        fn read_only_gated_volume() -> Self {
1802            Self {
1803                superuser: true,
1804                gated_volume: true,
1805                read_only: true,
1806                ..Self::unwritable()
1807            }
1808        }
1809    }
1810
1811    impl FilesystemProbe for StubFilesystem {
1812        fn identify(&self, _directory: &Path) -> io::Result<FilesystemIdentity> {
1813            Ok(self.identity.clone())
1814        }
1815
1816        fn is_writable(&self, _directory: &Path) -> io::Result<bool> {
1817            Ok(self.writable)
1818        }
1819
1820        fn runs_as_superuser(&self) -> bool {
1821            self.superuser
1822        }
1823
1824        fn is_on_privacy_gated_volume(&self, _directory: &Path) -> bool {
1825            self.gated_volume
1826        }
1827
1828        fn is_read_only(&self, _directory: &Path) -> bool {
1829            self.read_only
1830        }
1831    }
1832
1833    /// A path this build's own platform accepts.
1834    fn native(path: &Path) -> LocalAbsolutePath {
1835        LocalAbsolutePath::new(path.to_str().expect("the fixture path is unicode"))
1836            .expect("the fixture path is a storable local path")
1837    }
1838
1839    /// A path the *other* platform accepts, for the corrupt-row case.
1840    fn foreign() -> LocalAbsolutePath {
1841        if cfg!(windows) {
1842            LocalAbsolutePath::parse_for("/srv/rman", Unix)
1843        } else {
1844            LocalAbsolutePath::parse_for("C:\\rman", Windows)
1845        }
1846        .expect("the fixture is valid for the other platform")
1847    }
1848
1849    /// Creates a directory symlink, or a junction on Windows.
1850    ///
1851    /// Returns `false` when the platform refused, which on Windows means this
1852    /// account has neither Developer Mode nor the privilege. The caller then
1853    /// skips, because what refused would not be the code under test.
1854    #[cfg(windows)]
1855    fn link_dir(target: &Path, link: &Path) -> bool {
1856        std::process::Command::new("cmd")
1857            .arg("/C")
1858            .arg("mklink")
1859            .arg("/J")
1860            .arg(link)
1861            .arg(target)
1862            .output()
1863            .is_ok_and(|output| output.status.success())
1864    }
1865
1866    #[cfg(unix)]
1867    fn link_dir(target: &Path, link: &Path) -> bool {
1868        std::os::unix::fs::symlink(target, link).is_ok()
1869    }
1870
1871    /// An application-data layout, plus a `workspaces/` directory that is
1872    /// disjoint from all four of its directories.
1873    struct Fixture {
1874        root: tempfile::TempDir,
1875        paths: AppPaths,
1876        workspaces: PathBuf,
1877    }
1878
1879    impl Fixture {
1880        /// Preflights `path` as the host root, against the real filesystem.
1881        ///
1882        /// The construction every test below shares. The tests that are *about*
1883        /// the owner, or about a probe the machine cannot provide, build a
1884        /// [`RootPreflight`] themselves with `against` or `with_probe`; for the
1885        /// rest, the owner is ceremony rather than the thing under test.
1886        fn check(&self, path: &Path) -> Result<PreflightedRoot, RunnerRootError> {
1887            RootPreflight::new(&self.paths).check(&RootOwner::Host, &native(path))
1888        }
1889    }
1890
1891    fn fixture() -> Fixture {
1892        let root = tempfile::tempdir().expect("a temporary directory");
1893        let paths = AppPaths::rooted_at(root.path());
1894        paths.create_all().expect("the layout is created");
1895        let workspaces = root.path().join("workspaces");
1896        std::fs::create_dir(&workspaces).expect("the workspace parent is created");
1897        Fixture {
1898            root,
1899            paths,
1900            workspaces,
1901        }
1902    }
1903
1904    /// Every entry below `root`, carrying what a mutating check would change.
1905    fn snapshot(root: &Path) -> Vec<String> {
1906        fn walk(path: &Path, into: &mut Vec<String>) {
1907            let Ok(entries) = std::fs::read_dir(path) else {
1908                return;
1909            };
1910            for entry in entries.flatten() {
1911                let metadata = entry
1912                    .metadata()
1913                    .expect("an entry that was just listed can be inspected");
1914                #[cfg(unix)]
1915                let permissions = {
1916                    use std::os::unix::fs::PermissionsExt;
1917                    format!("{:04o}", metadata.permissions().mode() & 0o7777)
1918                };
1919                #[cfg(not(unix))]
1920                let permissions = format!("readonly={}", metadata.permissions().readonly());
1921                into.push(format!(
1922                    "{} dir={} len={} {permissions}",
1923                    entry.path().display(),
1924                    metadata.is_dir(),
1925                    metadata.len()
1926                ));
1927                if metadata.is_dir() {
1928                    walk(&entry.path(), into);
1929                }
1930            }
1931        }
1932        let mut entries = Vec::new();
1933        walk(root, &mut entries);
1934        entries.sort();
1935        entries
1936    }
1937
1938    // -- the platform default -----------------------------------------------
1939
1940    #[test]
1941    fn the_windows_default_is_the_system_drive_plus_rman() {
1942        // D1: `%SystemDrive%\rman`, "normally `C:\rman`". Driven by argument, so
1943        // the Linux and macOS legs assert the Windows rule too, and no case here
1944        // would pass if the drive letter were hard-coded.
1945        let cases = [
1946            ("C:\\Windows\\system32", "C:\\rman"),
1947            ("E:\\Windows\\system32", "E:\\rman"),
1948            ("c:/windows/system32", "C:\\rman"),
1949            ("Z:\\WINDOWS\\SYSTEM32", "Z:\\rman"),
1950            ("D:\\Windows", "D:\\rman"),
1951        ];
1952        for (system_directory, expected) in cases {
1953            let resolved =
1954                default_runner_root_from(PlatformDefault::WindowsSystemDirectory(system_directory))
1955                    .expect("a drive path resolves");
1956            assert_eq!(
1957                resolved.as_str(),
1958                expected,
1959                "system directory {system_directory:?}"
1960            );
1961            assert_eq!(resolved.platform(), Windows);
1962        }
1963    }
1964
1965    #[test]
1966    fn a_system_directory_that_is_not_a_local_drive_fails_with_the_remediation() {
1967        for system_directory in [
1968            "\\\\nas\\share\\system32",
1969            "\\\\?\\C:\\Windows\\system32",
1970            "C:\\",
1971            "windows\\system32",
1972            "",
1973        ] {
1974            let error =
1975                default_runner_root_from(PlatformDefault::WindowsSystemDirectory(system_directory))
1976                    .expect_err("an unusable system directory must not resolve");
1977            let message = error.to_string();
1978            assert!(
1979                message.contains("host set-runtime-root"),
1980                "the message must name the command that fixes it: {message}"
1981            );
1982        }
1983    }
1984
1985    #[test]
1986    fn the_application_runtime_directory_arm_changes_nothing() {
1987        // The macOS and Linux Definition of Done: "byte-identical to their
1988        // previous runtime paths". Asserted on every platform, because the arm
1989        // is the same code everywhere.
1990        let root = tempfile::tempdir().expect("a temporary directory");
1991        let paths = AppPaths::rooted_at(root.path());
1992        let resolved = default_runner_root_from(PlatformDefault::ApplicationRuntimeDirectory(
1993            paths.runtime_dir(),
1994        ))
1995        .expect("a resolved runtime directory is storable");
1996        assert_eq!(resolved.as_path(), paths.runtime_dir());
1997    }
1998
1999    #[cfg(not(windows))]
2000    #[test]
2001    fn the_macos_and_linux_defaults_are_the_existing_runtime_directory() {
2002        let discovered = AppPaths::discover().expect("a home directory exists on every CI leg");
2003        assert_eq!(
2004            default_runner_root(&discovered)
2005                .expect("the discovered layout resolves")
2006                .as_path(),
2007            discovered.runtime_dir(),
2008            "moving the Unix defaults would relocate live workspaces for no reason"
2009        );
2010
2011        let root = tempfile::tempdir().expect("a temporary directory");
2012        let rooted = AppPaths::rooted_at(root.path());
2013        assert_eq!(
2014            default_runner_root(&rooted)
2015                .expect("an explicit root resolves")
2016                .as_path(),
2017            rooted.runtime_dir()
2018        );
2019    }
2020
2021    #[cfg(windows)]
2022    #[test]
2023    fn the_windows_default_is_this_machines_system_drive() {
2024        let paths = AppPaths::rooted_at(Path::new("C:\\does-not-matter"));
2025        let resolved = default_runner_root(&paths).expect("this host has a system directory");
2026        let text = resolved.as_str();
2027        assert_eq!(
2028            &text[1..],
2029            format!(":\\{WINDOWS_RUNNER_ROOT_NAME}"),
2030            "the default is <system-drive> plus {WINDOWS_RUNNER_ROOT_NAME}, got {text}"
2031        );
2032        assert!(text.starts_with(|c: char| c.is_ascii_uppercase()));
2033    }
2034
2035    #[cfg(windows)]
2036    #[test]
2037    #[serial_test::serial(environment)]
2038    fn the_windows_default_ignores_a_rewritten_system_drive_variable() {
2039        // `%SystemDrive%` is process environment and is writable by whatever
2040        // started this process. The value decides where a recursive cleanup
2041        // later runs, so it is read from the kernel instead.
2042        let paths = AppPaths::rooted_at(Path::new("C:\\does-not-matter"));
2043        let before = default_runner_root(&paths).expect("this host has a system directory");
2044
2045        let restore_drive = std::env::var_os("SystemDrive");
2046        let restore_root = std::env::var_os("SystemRoot");
2047        // SAFETY: the suite is serialised on this key by `serial_test`, and both
2048        // variables are restored below before any assertion can unwind past it.
2049        unsafe {
2050            std::env::set_var("SystemDrive", "Q:");
2051            std::env::set_var("SystemRoot", "Q:\\Windows");
2052        }
2053        let after = default_runner_root(&paths);
2054        // SAFETY: as above.
2055        unsafe {
2056            match restore_drive {
2057                Some(value) => std::env::set_var("SystemDrive", value),
2058                None => std::env::remove_var("SystemDrive"),
2059            }
2060            match restore_root {
2061                Some(value) => std::env::set_var("SystemRoot", value),
2062                None => std::env::remove_var("SystemRoot"),
2063            }
2064        }
2065
2066        let after = after.expect("the kernel still answers");
2067        assert_eq!(after, before);
2068        assert_ne!(after.as_str(), "Q:\\rman");
2069    }
2070
2071    // -- overlap ------------------------------------------------------------
2072
2073    #[test]
2074    fn overlap_is_decided_component_by_component_on_both_platforms() {
2075        let cases = [
2076            (Unix, "/srv/rman", "/srv/rman", Overlap::Same),
2077            (Unix, "/srv/rman/s1", "/srv/rman", Overlap::Inside),
2078            (Unix, "/srv", "/srv/rman", Overlap::Contains),
2079            (Unix, "/srv/rman", "/srv/other", Overlap::Disjoint),
2080            // A prefix of the *text* is not a prefix of the *path*.
2081            (Unix, "/srv/rman-old", "/srv/rman", Overlap::Disjoint),
2082            (Unix, "/", "/srv/rman", Overlap::Contains),
2083            // Unix is case-sensitive; refusing to distinguish these would reject
2084            // two directories an operator legitimately has.
2085            (Unix, "/srv/Rman", "/srv/rman", Overlap::Disjoint),
2086            (Windows, "C:\\rman", "C:\\rman", Overlap::Same),
2087            (Windows, "C:\\RMAN", "c:\\rman", Overlap::Same),
2088            (Windows, "C:\\rman\\s1", "C:\\rman", Overlap::Inside),
2089            (Windows, "C:\\", "C:\\rman", Overlap::Contains),
2090            // Different volumes never overlap, which is the whole reason the
2091            // drive is an ordinary component here.
2092            (Windows, "D:\\rman", "C:\\rman", Overlap::Disjoint),
2093            (Windows, "C:\\rman-old", "C:\\rman", Overlap::Disjoint),
2094        ];
2095        for (platform, candidate, other, expected) in cases {
2096            assert_eq!(
2097                overlap_of(candidate, other, platform),
2098                expected,
2099                "{platform}: {candidate:?} vs {other:?}"
2100            );
2101        }
2102    }
2103
2104    #[test]
2105    fn a_filesystem_root_never_reaches_the_preflight() {
2106        // The pure layer refuses it, so the preflight's own root guard is a
2107        // second line rather than the first: a root is where a recursive cleanup
2108        // would take the whole volume.
2109        for (raw, platform) in [("/", Unix), ("C:\\", Windows), ("c:/", Windows)] {
2110            assert!(
2111                LocalAbsolutePath::parse_for(raw, platform).is_err(),
2112                "{raw:?} must not be storable"
2113            );
2114        }
2115        let (root, below) = if cfg!(windows) {
2116            ("C:\\", "C:\\rman")
2117        } else {
2118            ("/", "/srv")
2119        };
2120        assert!(is_filesystem_root(Path::new(root)));
2121        assert!(!is_filesystem_root(Path::new(below)));
2122    }
2123
2124    // -- the preflight, against the real filesystem --------------------------
2125
2126    #[test]
2127    fn an_existing_writable_local_directory_is_accepted() {
2128        let fixture = fixture();
2129        let root = fixture.workspaces.join("rman");
2130        std::fs::create_dir(&root).expect("the root is created");
2131
2132        let checked = fixture
2133            .check(&root)
2134            .expect("a plain writable directory on this machine is usable");
2135
2136        assert!(checked.exists());
2137        assert_eq!(checked.leaf_to_create(), None);
2138        assert_eq!(checked.filesystem().locality, Locality::Local);
2139        assert_eq!(
2140            checked.canonical(),
2141            plain(&std::fs::canonicalize(&root).expect("it exists"))
2142        );
2143    }
2144
2145    #[test]
2146    fn a_missing_leaf_below_a_writable_parent_is_accepted_and_not_created() {
2147        let fixture = fixture();
2148        let root = fixture.workspaces.join("rman");
2149
2150        let checked = fixture.check(&root).expect("a creatable leaf is usable");
2151
2152        assert!(!checked.exists());
2153        assert_eq!(checked.leaf_to_create(), Some(root.as_path()));
2154        assert!(
2155            !root.exists(),
2156            "creation is the caller's explicit step, never the preflight's"
2157        );
2158    }
2159
2160    #[test]
2161    fn more_than_one_missing_level_is_refused_with_the_deepest_directory() {
2162        let fixture = fixture();
2163        let root = fixture.workspaces.join("a").join("b");
2164
2165        let error = fixture
2166            .check(&root)
2167            .expect_err("only the leaf may be missing");
2168        let RunnerRootError::MissingParents {
2169            deepest_existing, ..
2170        } = &error
2171        else {
2172            panic!("expected MissingParents, got {error}");
2173        };
2174        assert_eq!(deepest_existing, &fixture.workspaces);
2175    }
2176
2177    #[test]
2178    fn an_existing_file_is_refused() {
2179        let fixture = fixture();
2180        let root = fixture.workspaces.join("rman");
2181        std::fs::write(&root, b"not a directory").expect("the file is created");
2182
2183        let error = fixture
2184            .check(&root)
2185            .expect_err("a file is not a runner root");
2186        assert!(
2187            matches!(error, RunnerRootError::ExistingFile { .. }),
2188            "got {error}"
2189        );
2190    }
2191
2192    #[test]
2193    fn a_file_where_the_parent_should_be_is_refused() {
2194        let fixture = fixture();
2195        let file = fixture.workspaces.join("notes.txt");
2196        std::fs::write(&file, b"notes").expect("the file is created");
2197        let root = file.join("rman");
2198
2199        let error = fixture
2200            .check(&root)
2201            .expect_err("nothing can be created inside a file");
2202        assert!(
2203            matches!(error, RunnerRootError::ParentIsNotADirectory { .. }),
2204            "got {error}"
2205        );
2206    }
2207
2208    #[test]
2209    fn a_linked_root_is_refused_rather_than_followed() {
2210        let fixture = fixture();
2211        let target = fixture.workspaces.join("real");
2212        std::fs::create_dir(&target).expect("the target is created");
2213        let root = fixture.workspaces.join("rman");
2214        if !link_dir(&target, &root) {
2215            return;
2216        }
2217
2218        let error = fixture
2219            .check(&root)
2220            .expect_err("a runner root is the base of a recursive cleanup");
2221        assert!(
2222            matches!(error, RunnerRootError::Symlinked { .. }),
2223            "got {error}"
2224        );
2225    }
2226
2227    #[test]
2228    fn a_link_whose_target_is_gone_is_still_reported_as_a_link() {
2229        // A junction left behind by a removed target is the ordinary way this
2230        // shows up. It cannot be canonicalised, so a preflight that resolved
2231        // before it classified would answer "cannot inspect … not found" for a
2232        // name the operator can see in a directory listing.
2233        let fixture = fixture();
2234        let root = fixture.workspaces.join("rman");
2235        if !link_dir(&fixture.workspaces.join("gone"), &root) {
2236            return;
2237        }
2238
2239        let error = fixture
2240            .check(&root)
2241            .expect_err("a dangling link is not a runner root");
2242        assert!(
2243            matches!(error, RunnerRootError::Symlinked { .. }),
2244            "got {error}"
2245        );
2246    }
2247
2248    #[test]
2249    fn a_link_in_the_path_cannot_smuggle_a_root_into_application_data() {
2250        let fixture = fixture();
2251        let bridge = fixture.workspaces.join("bridge");
2252        if !link_dir(fixture.paths.state_dir(), &bridge) {
2253            return;
2254        }
2255        // Lexically unrelated to `state/`; it is only the resolved path that
2256        // lands inside it.
2257        let root = bridge.join("rman");
2258
2259        let error = fixture
2260            .check(&root)
2261            .expect_err("the canonical check must see through the link");
2262        let RunnerRootError::Overlaps { relation, .. } = &error else {
2263            panic!("expected Overlaps, got {error}");
2264        };
2265        assert_eq!(*relation, Overlap::Inside);
2266    }
2267
2268    #[test]
2269    fn a_root_that_collides_with_application_data_is_refused() {
2270        let fixture = fixture();
2271        let preflight = RootPreflight::new(&fixture.paths);
2272        let cases = [
2273            // The directory itself.
2274            (fixture.paths.state_dir().to_path_buf(), Overlap::Same),
2275            // Inside it.
2276            (fixture.paths.logs_dir().join("rman"), Overlap::Inside),
2277            // Above it: a cleanup under this root would take the database too.
2278            (fixture.root.path().to_path_buf(), Overlap::Contains),
2279        ];
2280        for (candidate, expected) in cases {
2281            let error = preflight
2282                .check(&RootOwner::Host, &native(&candidate))
2283                .expect_err("application data may not share a tree with runner workspaces");
2284            let RunnerRootError::Overlaps { relation, .. } = &error else {
2285                panic!("{} gave {error}", candidate.display());
2286            };
2287            assert_eq!(*relation, expected, "{}", candidate.display());
2288        }
2289    }
2290
2291    #[test]
2292    fn the_macos_shaped_layout_still_accepts_its_own_runtime_directory() {
2293        // On macOS `config`, `state`, `runtime` and `logs` all live under one
2294        // `Application Support` directory, so the platform default runner root
2295        // is *inside* the configuration directory. That nesting is the product's
2296        // own layout, not an operator mistake.
2297        let root = tempfile::tempdir().expect("a temporary directory");
2298        let base = root.path();
2299        let paths = AppPaths::from_directories(
2300            base,
2301            base.join("state"),
2302            base.join("runtime"),
2303            base.join("logs"),
2304        );
2305        paths.create_all().expect("the layout is created");
2306        let preflight = RootPreflight::new(&paths);
2307
2308        preflight
2309            .check(&RootOwner::Host, &native(paths.runtime_dir()))
2310            .expect("the platform default must pass its own preflight");
2311        preflight
2312            .check(
2313                &RootOwner::Host,
2314                &native(&paths.runtime_dir().join("nested")),
2315            )
2316            .expect("a directory below the runtime directory is still the runner area");
2317
2318        for refused in [base.to_path_buf(), base.join("state"), base.join("beside")] {
2319            let error = preflight
2320                .check(&RootOwner::Host, &native(&refused))
2321                .expect_err("only the runtime subtree is exempt");
2322            assert!(
2323                matches!(error, RunnerRootError::Overlaps { .. }),
2324                "{} gave {error}",
2325                refused.display()
2326            );
2327        }
2328    }
2329
2330    #[test]
2331    fn two_roots_may_not_contain_one_another() {
2332        let fixture = fixture();
2333        let host = fixture.workspaces.join("host");
2334        let repository = host.join("acme");
2335        let other = fixture.workspaces.join("other");
2336        std::fs::create_dir_all(&repository).expect("both roots are created");
2337        std::fs::create_dir(&other).expect("the third root is created");
2338
2339        let owner = RootOwner::Repository("acme/widgets".to_string());
2340        let preflight = RootPreflight::new(&fixture.paths)
2341            .against(RootOwner::Host, native(&host))
2342            .against(
2343                RootOwner::Repository("acme/gadgets".to_string()),
2344                native(&other),
2345            );
2346
2347        let error = preflight
2348            .check(&owner, &native(&repository))
2349            .expect_err("a repository root inside the host root is refused");
2350        let RunnerRootError::Overlaps {
2351            relation,
2352            other_owner,
2353            ..
2354        } = &error
2355        else {
2356            panic!("expected Overlaps, got {error}");
2357        };
2358        assert_eq!(*relation, Overlap::Inside);
2359        assert_eq!(other_owner, &RootOwner::Host.to_string());
2360
2361        let error = preflight
2362            .check(&owner, &native(&other))
2363            .expect_err("two repositories may not share a root");
2364        assert!(
2365            matches!(error, RunnerRootError::Overlaps { .. }),
2366            "got {error}"
2367        );
2368    }
2369
2370    #[test]
2371    fn a_root_does_not_overlap_itself_when_it_is_revalidated() {
2372        let fixture = fixture();
2373        let root = fixture.workspaces.join("acme");
2374        std::fs::create_dir(&root).expect("the root is created");
2375        let owner = RootOwner::Repository("acme/widgets".to_string());
2376
2377        RootPreflight::new(&fixture.paths)
2378            .against(owner.clone(), native(&root))
2379            .check(&owner, &native(&root))
2380            .expect("re-checking a stored setting must not report it against itself");
2381    }
2382
2383    #[test]
2384    fn a_row_written_on_another_operating_system_fails_closed() {
2385        let fixture = fixture();
2386        let error = RootPreflight::new(&fixture.paths)
2387            .check(&RootOwner::Host, &foreign())
2388            .expect_err("a foreign path is corrupt state on this host");
2389        assert!(
2390            matches!(error, RunnerRootError::ForeignPlatform { .. }),
2391            "got {error}"
2392        );
2393    }
2394
2395    // -- the preflight, against a stubbed platform ---------------------------
2396
2397    #[test]
2398    fn a_remote_filesystem_is_refused() {
2399        let fixture = fixture();
2400        let root = fixture.workspaces.join("rman");
2401        std::fs::create_dir(&root).expect("the root is created");
2402        let probe = StubFilesystem::saying(FilesystemIdentity::remote("nfs"));
2403
2404        let error = RootPreflight::with_probe(&fixture.paths, &probe)
2405            .check(&RootOwner::Host, &native(&root))
2406            .expect_err("a network share may not hold runner workspaces");
2407        assert!(
2408            matches!(error, RunnerRootError::RemoteFilesystem { .. }),
2409            "got {error}"
2410        );
2411    }
2412
2413    #[test]
2414    fn a_filesystem_this_host_cannot_classify_fails_closed() {
2415        let fixture = fixture();
2416        let root = fixture.workspaces.join("rman");
2417        std::fs::create_dir(&root).expect("the root is created");
2418        let probe =
2419            StubFilesystem::saying(FilesystemIdentity::unprovable("filesystem type 0x00001234"));
2420
2421        let error = RootPreflight::with_probe(&fixture.paths, &probe)
2422            .check(&RootOwner::Host, &native(&root))
2423            .expect_err("unprovable locality is a refusal, not a shrug");
2424        assert!(
2425            matches!(error, RunnerRootError::UnprovableFilesystem { .. }),
2426            "got {error}"
2427        );
2428    }
2429
2430    /// The regression this pair exists for.
2431    ///
2432    /// A boot-mode daemon on macOS is denied an external volume by the privacy
2433    /// layer, not by the mode bits, and it cannot raise the consent prompt that
2434    /// would say so. Reported as `NotWritable`, the refusal sent the operator to
2435    /// `chmod` -- which cannot fix it, and which they can keep trying forever
2436    /// while every launch fails once per poll.
2437    #[cfg(target_os = "macos")]
2438    #[test]
2439    fn a_refusal_the_superuser_received_names_the_privacy_control_not_the_permissions() {
2440        let fixture = fixture();
2441        let existing = fixture.workspaces.join("rman");
2442        std::fs::create_dir(&existing).expect("the root is created");
2443        let probe = StubFilesystem::unwritable_to_the_superuser();
2444        let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2445
2446        let error = preflight
2447            .check(&RootOwner::Host, &native(&existing))
2448            .expect_err("a root the service cannot write is unusable");
2449
2450        assert!(
2451            matches!(error, RunnerRootError::DeniedByPrivacyPolicy { .. }),
2452            "a superuser cannot be refused by file permissions, so this is the \
2453             privacy layer: {error}"
2454        );
2455        let rendered = error.to_string();
2456        assert!(
2457            rendered.contains("Full Disk Access"),
2458            "the refusal must name the control that grants it: {rendered}"
2459        );
2460        assert!(
2461            rendered.contains(&RootOwner::Host.remediation()),
2462            "the refusal must still show the command that moves the root: {rendered}"
2463        );
2464    }
2465
2466    /// The over-blaming half of the same branch: a superuser can be refused for
2467    /// perfectly ordinary reasons -- a read-only mount, a SIP-protected
2468    /// directory -- and telling that operator to grant Full Disk Access sends
2469    /// them to a control that cannot fix it while hiding the real diagnosis.
2470    #[cfg(target_os = "macos")]
2471    #[test]
2472    fn a_superuser_refused_on_the_startup_disk_is_not_blamed_on_the_privacy_layer() {
2473        let fixture = fixture();
2474        let existing = fixture.workspaces.join("rman");
2475        std::fs::create_dir(&existing).expect("the root is created");
2476        let probe = StubFilesystem::unwritable_to_the_superuser_on_the_startup_disk();
2477        let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2478
2479        let error = preflight
2480            .check(&RootOwner::Host, &native(&existing))
2481            .expect_err("an unwritable root is unusable");
2482
2483        assert!(
2484            matches!(error, RunnerRootError::NotWritable { .. }),
2485            "the privacy layer gates no directory on the startup disk: {error}"
2486        );
2487    }
2488
2489    /// A read-only mount refuses everybody, superuser included, and mounts
2490    /// under `/Volumes` like any other volume -- a disk image, a write-protected
2491    /// external disk. Granting Full Disk Access cannot make it writable, so
2492    /// blaming the privacy layer sends the operator to a control that changes
2493    /// nothing and hides the real cause.
2494    #[cfg(target_os = "macos")]
2495    #[test]
2496    fn a_read_only_volume_is_not_blamed_on_the_privacy_layer() {
2497        let fixture = fixture();
2498        let existing = fixture.workspaces.join("rman");
2499        std::fs::create_dir(&existing).expect("the root is created");
2500        let probe = StubFilesystem::read_only_gated_volume();
2501        let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2502
2503        let error = preflight
2504            .check(&RootOwner::Host, &native(&existing))
2505            .expect_err("a read-only root is unusable");
2506
2507        assert!(
2508            matches!(error, RunnerRootError::NotWritable { .. }),
2509            "consent cannot make a read-only mount writable: {error}"
2510        );
2511    }
2512
2513    /// The requested path must survive the privacy branch. It returns before the
2514    /// leaf/parent split below, so a leaf that does not exist yet would
2515    /// otherwise be replaced by its parent and never named.
2516    #[cfg(target_os = "macos")]
2517    #[test]
2518    fn a_privacy_refusal_names_the_root_that_was_asked_for_and_the_one_that_refused() {
2519        let fixture = fixture();
2520        let leaf = fixture.workspaces.join("not-created-yet");
2521        let probe = StubFilesystem::unwritable_to_the_superuser();
2522        let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2523
2524        let error = preflight
2525            .check(&RootOwner::Host, &native(&leaf))
2526            .expect_err("a root the service cannot create is unusable");
2527
2528        let rendered = error.to_string();
2529        assert!(
2530            rendered.contains("not-created-yet"),
2531            "the root the operator asked for is missing from the refusal: {rendered}"
2532        );
2533        assert!(
2534            rendered.contains(&fixture.workspaces.display().to_string()),
2535            "the directory that actually refused is missing from the refusal: {rendered}"
2536        );
2537    }
2538
2539    /// The other half: an ordinary account really is refused by permissions, and
2540    /// must keep being told so.
2541    #[test]
2542    fn a_refusal_an_ordinary_account_received_still_names_file_permissions() {
2543        let fixture = fixture();
2544        let existing = fixture.workspaces.join("rman");
2545        std::fs::create_dir(&existing).expect("the root is created");
2546        let probe = StubFilesystem::unwritable();
2547        let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2548
2549        let error = preflight
2550            .check(&RootOwner::Host, &native(&existing))
2551            .expect_err("an unwritable root is unusable");
2552
2553        assert!(
2554            matches!(error, RunnerRootError::NotWritable { .. }),
2555            "got {error}"
2556        );
2557    }
2558
2559    #[test]
2560    fn an_unwritable_directory_and_an_unwritable_parent_are_reported_apart() {
2561        let fixture = fixture();
2562        let existing = fixture.workspaces.join("rman");
2563        std::fs::create_dir(&existing).expect("the root is created");
2564        let missing = fixture.workspaces.join("other");
2565        let probe = StubFilesystem::unwritable();
2566        let preflight = RootPreflight::with_probe(&fixture.paths, &probe);
2567
2568        let error = preflight
2569            .check(&RootOwner::Host, &native(&existing))
2570            .expect_err("an unwritable root is unusable");
2571        assert!(
2572            matches!(error, RunnerRootError::NotWritable { .. }),
2573            "got {error}"
2574        );
2575        assert!(
2576            error.to_string().contains(&RootOwner::Host.remediation()),
2577            "the refusal must show the command that fixes it: {error}"
2578        );
2579
2580        let error = preflight
2581            .check(&RootOwner::Host, &native(&missing))
2582            .expect_err("an unwritable parent cannot hold a new leaf");
2583        let RunnerRootError::ParentNotWritable { parent, leaf, .. } = &error else {
2584            panic!("expected ParentNotWritable, got {error}");
2585        };
2586        assert_eq!(parent, &fixture.workspaces);
2587        assert_eq!(leaf, &missing);
2588        assert!(
2589            error.to_string().contains(&RootOwner::Host.remediation()),
2590            "the refusal must show the command that fixes it: {error}"
2591        );
2592
2593        let repository = RootOwner::Repository("acme/widgets".to_string());
2594        let error = preflight
2595            .check(&repository, &native(&missing))
2596            .expect_err("an unwritable parent cannot hold a new leaf");
2597        assert!(
2598            error.to_string().contains(&repository.remediation()),
2599            "a repository root must name its own command: {error}"
2600        );
2601    }
2602
2603    #[test]
2604    fn nothing_is_created_removed_or_repermissioned_by_any_verdict() {
2605        // "Validation performs no deletion or permission mutation." Asserted
2606        // over the accepting path and every refusing path at once, because a
2607        // probe that wrote a marker would only show up on one of them.
2608        let fixture = fixture();
2609        let existing = fixture.workspaces.join("rman");
2610        std::fs::create_dir(&existing).expect("the root is created");
2611        let file = fixture.workspaces.join("notes.txt");
2612        std::fs::write(&file, b"operator data").expect("the file is created");
2613
2614        let before = snapshot(fixture.root.path());
2615
2616        let stub = StubFilesystem::unwritable();
2617        let host_preflight = RootPreflight::new(&fixture.paths);
2618        let stub_preflight = RootPreflight::with_probe(&fixture.paths, &stub);
2619        for preflight in [&host_preflight, &stub_preflight] {
2620            for candidate in [
2621                existing.clone(),
2622                fixture.workspaces.join("missing"),
2623                fixture.workspaces.join("a").join("b"),
2624                file.clone(),
2625                file.join("leaf"),
2626                fixture.paths.state_dir().to_path_buf(),
2627            ] {
2628                let _ = preflight.check(&RootOwner::Host, &native(&candidate));
2629            }
2630        }
2631
2632        assert_eq!(
2633            before,
2634            snapshot(fixture.root.path()),
2635            "the preflight changed the filesystem"
2636        );
2637    }
2638
2639    // -- the real probe ------------------------------------------------------
2640
2641    #[test]
2642    fn this_machines_temporary_directory_is_local_and_writable() {
2643        let root = tempfile::tempdir().expect("a temporary directory");
2644        let canonical = plain(&std::fs::canonicalize(root.path()).expect("it exists"));
2645
2646        let identity = HostFilesystem
2647            .identify(&canonical)
2648            .expect("the platform answers");
2649        assert_eq!(
2650            identity.locality,
2651            Locality::Local,
2652            "the suite's own temporary directory reported {identity:?}; a CI leg whose \
2653             temporary filesystem is unknown to this table would refuse every runner root"
2654        );
2655        assert!(
2656            HostFilesystem
2657                .is_writable(&canonical)
2658                .expect("the platform answers"),
2659            "a directory this process just created must be writable"
2660        );
2661    }
2662
2663    #[cfg(windows)]
2664    #[test]
2665    fn the_system_drive_root_is_writable_when_a_directory_can_be_created_in_it() {
2666        // The default DACL of the system drive root grants `Authenticated
2667        // Users` `AD` without `WD`, so a probe that asks for
2668        // `FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY` refuses `C:\` for every
2669        // process that is not elevated — and with it the product's own default
2670        // `C:\rman`. Probe rather than ask: the assertion only runs on a host
2671        // where the account demonstrably can create the directory.
2672        let paths = AppPaths::rooted_at(Path::new("C:\\does-not-matter"));
2673        let default = default_runner_root(&paths).expect("this host has a system directory");
2674        let parent = default
2675            .as_path()
2676            .parent()
2677            .expect("the default root is one level below the system drive")
2678            .to_path_buf();
2679
2680        let probe = parent.join(format!("rman-preflight-probe-{}", std::process::id()));
2681        if std::fs::create_dir(&probe).is_err() {
2682            return;
2683        }
2684        let writable = HostFilesystem.is_writable(&parent);
2685        std::fs::remove_dir(&probe).expect("the probe is removed");
2686
2687        assert!(
2688            writable.expect("the platform answers"),
2689            "{} accepts a new directory, so the preflight must not refuse the default \
2690             runner root as unwritable",
2691            parent.display()
2692        );
2693    }
2694
2695    #[cfg(unix)]
2696    #[test]
2697    fn a_directory_this_account_cannot_write_is_reported_as_such() {
2698        use std::os::unix::fs::PermissionsExt;
2699
2700        let root = tempfile::tempdir().expect("a temporary directory");
2701        let locked = root.path().join("locked");
2702        std::fs::create_dir(&locked).expect("the directory is created");
2703        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555))
2704            .expect("the directory is made unwritable");
2705
2706        // Root ignores the mode bits, and containers routinely run as root.
2707        // Probe rather than ask.
2708        let probe = locked.join("probe");
2709        let is_root = std::fs::create_dir(&probe).is_ok();
2710        if is_root {
2711            std::fs::remove_dir(&probe).expect("the probe is removed");
2712        }
2713        let writable = HostFilesystem.is_writable(&locked);
2714        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755))
2715            .expect("the directory is restored");
2716        if is_root {
2717            return;
2718        }
2719        assert!(
2720            !writable.expect("the platform answers"),
2721            "a 0555 directory must not be reported writable"
2722        );
2723    }
2724
2725    // -- derived paths -------------------------------------------------------
2726
2727    #[test]
2728    fn a_derived_child_is_one_component_below_the_root() {
2729        let root = LocalAbsolutePath::parse_for("/srv/rman", Unix).expect("a valid root");
2730        assert_eq!(
2731            derive_child(&root, "s1").expect("a valid slot").as_str(),
2732            "/srv/rman/s1"
2733        );
2734        let root = LocalAbsolutePath::parse_for("C:\\rman", Windows).expect("a valid root");
2735        assert_eq!(
2736            derive_child(&root, "0123456789ab")
2737                .expect("a valid attempt")
2738                .as_str(),
2739            "C:\\rman\\0123456789ab"
2740        );
2741        for name in ["..", "a/b", "", "."] {
2742            assert!(
2743                derive_child(&root, name).is_err(),
2744                "{name:?} must not be a derived child"
2745            );
2746        }
2747    }
2748
2749    #[test]
2750    fn containment_is_proven_lexically_and_after_resolution() {
2751        let fixture = fixture();
2752        let directory = fixture.workspaces.join("rman");
2753        std::fs::create_dir(&directory).expect("the root is created");
2754        let root = native(&directory);
2755
2756        let slot = derive_child(&root, "s1").expect("a valid slot");
2757        verify_containment(&root, &slot)
2758            .expect("a slot that does not exist yet is still contained");
2759        std::fs::create_dir(slot.as_path()).expect("the slot is created");
2760        verify_containment(&root, &slot).expect("an existing slot is contained");
2761
2762        let sibling = native(&fixture.workspaces.join("elsewhere"));
2763        assert!(
2764            matches!(
2765                verify_containment(&root, &sibling),
2766                Err(RunnerRootError::Escapes { .. })
2767            ),
2768            "a sibling is not contained"
2769        );
2770        assert!(
2771            matches!(
2772                verify_containment(&root, &root),
2773                Err(RunnerRootError::Escapes { .. })
2774            ),
2775            "the root is not strictly inside itself"
2776        );
2777    }
2778
2779    #[test]
2780    fn a_link_inside_the_root_that_points_outside_it_is_not_contained() {
2781        let fixture = fixture();
2782        let root = fixture.workspaces.join("rman");
2783        std::fs::create_dir(&root).expect("the root is created");
2784        let outside = fixture.workspaces.join("outside");
2785        std::fs::create_dir(&outside).expect("the escape target is created");
2786        let escape = root.join("s1");
2787        if !link_dir(&outside, &escape) {
2788            return;
2789        }
2790
2791        let error = verify_containment(&native(&root), &native(&escape))
2792            .expect_err("cleanup may not follow a link out of the root it was given");
2793        assert!(
2794            matches!(error, RunnerRootError::Escapes { .. }),
2795            "got {error}"
2796        );
2797    }
2798
2799    // -- messages ------------------------------------------------------------
2800
2801    #[test]
2802    fn each_owner_names_the_command_that_changes_it() {
2803        assert_eq!(
2804            RootOwner::Host.remediation(),
2805            "runner-manager host set-runtime-root --path <PATH>"
2806        );
2807        assert_eq!(
2808            RootOwner::Repository("acme/widgets".to_string()).remediation(),
2809            "runner-manager repo set-workspace acme/widgets --mode persistent --path <PATH>"
2810        );
2811        assert!(RootOwner::Host.to_string().contains("host runner root"));
2812        assert!(
2813            RootOwner::Repository("acme/widgets".to_string())
2814                .to_string()
2815                .contains("acme/widgets")
2816        );
2817    }
2818
2819    #[test]
2820    fn a_refusal_names_the_paths_and_says_what_to_do_about_it() {
2821        // These strings are what an operator sees in the CLI, the TUI and the
2822        // daemon log, so the wording is pinned rather than left to the next
2823        // edit of the enum.
2824        let fixture = fixture();
2825        let root = fixture.workspaces.join("rman");
2826        std::fs::create_dir(&root).expect("the root is created");
2827
2828        let probe = StubFilesystem::saying(FilesystemIdentity::remote("nfs"));
2829        let message = RootPreflight::with_probe(&fixture.paths, &probe)
2830            .check(&RootOwner::Host, &native(&root))
2831            .expect_err("a network share is refused")
2832            .to_string();
2833        assert!(message.contains("nfs"), "{message}");
2834        assert!(message.contains("local volume"), "{message}");
2835
2836        let message = fixture
2837            .check(fixture.paths.state_dir())
2838            .expect_err("application data is protected")
2839            .to_string();
2840        assert!(
2841            message.contains(&fixture.paths.state_dir().display().to_string()),
2842            "the directory that refused must be named: {message}"
2843        );
2844        assert!(
2845            message.contains("the application state directory"),
2846            "the operator must be told what it collided with: {message}"
2847        );
2848        assert!(
2849            message.contains("outside the application data tree"),
2850            "the message must say what to do instead: {message}"
2851        );
2852    }
2853}