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