Skip to main content

runner_manager_platform/
runner_root.rs

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