Skip to main content

runner_manager_platform/
paths.rs

1// owner: d1-platform-core
2
3//! The four application-data directories the daemon owns, resolved to
4//! platform-standard locations.
5//!
6//! `05-infrastructure.md` names them and says what each holds:
7//!
8//! ```text
9//! config/      non-secret TOML and SQLite database
10//! state/       agent lock, attempt journal, retained runner package/cache
11//! runtime/     per-attempt disposable directories
12//! logs/        rotating redacted agent diagnostics
13//! ```
14//!
15//! and then states the rule this module exists to enforce: *"Platform-standard
16//! application-data directories are used; no repository or runner material is
17//! stored in the current working directory by default."*
18//!
19//! ## The invariant, and how it is tested
20//!
21//! "Not the current working directory" is easy to satisfy by accident and easy
22//! to lose by accident — one `PathBuf::from("state")` anywhere in the
23//! resolution chain and the daemon starts writing runner workspaces wherever
24//! it happened to be launched from. The property that actually holds it is
25//! stronger and is what the tests assert: **the resolved paths do not change
26//! when the process changes directory.** A resolver that consults
27//! `current_dir()` fails that immediately, whereas an assertion phrased as "the
28//! path is not inside the current directory" passes for a developer whose shell
29//! happens to be somewhere else and fails for one whose shell is at `$HOME`.
30//!
31//! ## Placement, per platform
32//!
33//! | | `config` | `state` | `runtime`, `logs` |
34//! |---|---|---|---|
35//! | Windows | `%LOCALAPPDATA%\IvanMurzak\runner-manager\config` | `…\data\state` | `…\data\{runtime,logs}` |
36//! | macOS | `~/Library/Application Support/io.github.IvanMurzak.runner-manager` | `…/state` | `…/{runtime,logs}` |
37//! | Linux | `$XDG_CONFIG_HOME/runner-manager` | `$XDG_STATE_HOME/runner-manager` | `$XDG_DATA_HOME/runner-manager/{runtime,logs}` |
38//! | Linux, as `root` | `/var/lib/runner-manager/config` | `/var/lib/runner-manager/state` | `/var/lib/runner-manager/runtime`, `/var/log/runner-manager` |
39//!
40//! **`root` on Linux uses the system tree.** A boot service runs as `root`
41//! with an empty capability bounding set, so it can use only directories
42//! `root` owns. Under `sudo`, `$HOME` is the operator's on some distributions
43//! and `/root` on others, and an operator-owned tree is one the service cannot
44//! open. The system tree is the same whichever `sudo` ran, and lies outside
45//! the home directories a boot unit hides with `ProtectHome=`. A host whose
46//! `root` already has the per-account tree keeps it until `service install
47//! --start-at boot` moves it into the system tree.
48//!
49//! Three choices in that table are deliberate:
50//!
51//! - **Local, not roaming, on Windows.** `config_local_dir` rather than
52//!   `config_dir`. A SQLite database and a runner package cache have no
53//!   business being copied around a domain profile, and D13 stores this host's
54//!   token machine-scoped precisely because the agent is a machine-local
55//!   thing.
56//! - **`$XDG_STATE_HOME` on Linux.** That is the directory the XDG base
57//!   directory specification defines for exactly this content — state that
58//!   survives a restart but is neither configuration nor portable data. It has
59//!   no equivalent on Windows or macOS, so those fall back to a `state`
60//!   subdirectory of the local data directory.
61//! - **`runtime/` is *not* `$XDG_RUNTIME_DIR`.** That directory is a
62//!   size-limited tmpfs that the system clears when the user's session ends,
63//!   and D13's service starts at machine boot, outside any session — so it may
64//!   not exist at all. `runtime/` here holds per-attempt runner workspaces,
65//!   which are large and must outlive a logout, so it lives under the local
66//!   data directory instead.
67
68use std::fmt;
69use std::path::{Path, PathBuf};
70
71use directories::ProjectDirs;
72
73// The three segments below are `pub(crate)` rather than private because
74// `crate::secrets` resolves its own locations -- `%ProgramData%\<org>\<app>`,
75// `$XDG_DATA_HOME/<app>`, and the macOS keychain service name -- and it must
76// resolve them to the *same* product identity this module does. Spelled twice,
77// a drift between the two files would move the secret store out from under an
78// upgraded binary, and the token would read as simply absent. Visibility is the
79// whole of the change: no value, no doc comment, and no behaviour here differs.
80
81/// Reverse-domain qualifier; used on macOS only.
82pub(crate) const QUALIFIER: &str = "io.github";
83/// Organization segment; used on macOS and Windows only.
84pub(crate) const ORGANIZATION: &str = "IvanMurzak";
85/// Application segment; used on all three platforms.
86pub(crate) const APPLICATION: &str = "runner-manager";
87
88/// The four directory names, as `05-infrastructure.md` writes them. Also the
89/// layout [`AppPaths::rooted_at`] produces verbatim.
90const CONFIG: &str = "config";
91const STATE: &str = "state";
92const RUNTIME: &str = "runtime";
93const LOGS: &str = "logs";
94
95/// Where `root`'s configuration, state and runtime directories live on Linux.
96/// The machine-scoped secret store is under the same directory.
97pub const SYSTEM_DATA_ROOT: &str = "/var/lib/runner-manager";
98/// Where `root`'s diagnostics live on Linux.
99pub const SYSTEM_LOGS_DIR: &str = "/var/log/runner-manager";
100
101/// Something went wrong resolving or creating an application-data directory.
102#[derive(Debug, thiserror::Error)]
103pub enum PathsError {
104    /// The operating system reported no home directory for this account.
105    #[error(
106        "cannot determine a home directory for this account, so the platform-standard \
107         application-data directories cannot be resolved. A service account normally hits \
108         this when it is configured with no profile; give the account a home directory, or \
109         run the agent against an explicit root."
110    )]
111    NoHomeDirectory,
112
113    /// A directory could not be created.
114    #[error("cannot create the {purpose} directory {}: {source}", path.display())]
115    Create {
116        /// Which of the four directories failed.
117        purpose: &'static str,
118        /// The path that could not be created.
119        path: PathBuf,
120        /// The underlying filesystem error.
121        #[source]
122        source: std::io::Error,
123    },
124
125    /// A directory could not be created because its parent refused.
126    ///
127    /// Split out from [`PathsError::Create`] because the message a bare
128    /// `EACCES` produces is a dead end. It names the leaf, and the leaf is not
129    /// the obstacle: it does not exist yet, so it cannot be what refused.
130    /// `mkdir(2)` reports `EACCES` about the **parent**, and the parent here
131    /// is one of the shared directories this program deliberately does not
132    /// tighten -- `~/.local`, `~/.config`, or whatever the account's
133    /// application-data root resolves under. An operator handed the leaf path
134    /// goes and looks at a directory that is not there.
135    ///
136    /// Nothing about the permission policy changes on the strength of this.
137    /// Parents are still not this program's to tighten, and this still fails
138    /// rather than widening anything. The diagnosis is the whole of the
139    /// remedy: say which directory refused and what state it is in, so the
140    /// operator can decide.
141    #[error(
142        "cannot create the {purpose} directory {}: permission denied. The directory named is \
143         not the obstacle -- it does not exist yet, so it cannot be what refused; the refusal \
144         comes from its parent. {} is {parent_state}. This program will not change it: an \
145         intermediate directory is shared with every other application and is not the agent's \
146         to tighten. Grant this account write access to that directory, or run the agent \
147         against an explicit root under a directory it owns.",
148        path.display(),
149        path.parent().unwrap_or(path.as_path()).display()
150    )]
151    ParentDenies {
152        /// Which of the four directories failed.
153        purpose: &'static str,
154        /// The leaf that could not be created. The directory that actually
155        /// refused is its parent, which is derived rather than stored: keeping
156        /// both is redundant, and it pushed `Result<_, LoggingError>` past
157        /// clippy's `result_large_err` threshold, which is a fair complaint
158        /// about an error type carrying a path twice.
159        path: PathBuf,
160        /// What the platform can say about that parent: its mode on Unix,
161        /// whatever `std::fs` can report on Windows.
162        parent_state: String,
163        /// The underlying filesystem error.
164        #[source]
165        source: std::io::Error,
166    },
167}
168
169/// The four directories the daemon owns.
170///
171/// Resolved once and passed down, rather than recomputed at each use. Two
172/// resolutions in one process must agree, and the cheapest way to guarantee
173/// that is to only resolve once.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct AppPaths {
176    config: PathBuf,
177    state: PathBuf,
178    runtime: PathBuf,
179    logs: PathBuf,
180}
181
182impl AppPaths {
183    /// Rebuilds a previously resolved four-directory layout.
184    ///
185    /// Service registrations use this to carry the installing operator's
186    /// application-data directories across the account boundary to the daemon.
187    /// It is deliberately distinct from [`Self::rooted_at`]: these paths are
188    /// already the four leaves, and forcing them under a new root would point
189    /// the service at a second SQLite database.
190    #[must_use]
191    pub fn from_directories(
192        config: impl Into<PathBuf>,
193        state: impl Into<PathBuf>,
194        runtime: impl Into<PathBuf>,
195        logs: impl Into<PathBuf>,
196    ) -> Self {
197        Self {
198            config: config.into(),
199            state: state.into(),
200            runtime: runtime.into(),
201            logs: logs.into(),
202        }
203    }
204
205    /// Resolves the platform-standard locations for this account.
206    ///
207    /// # Errors
208    ///
209    /// [`PathsError::NoHomeDirectory`] when the operating system reports no
210    /// home directory, which is the only way this can fail: nothing is touched
211    /// on disk here. Use [`AppPaths::create_all`] for that.
212    pub fn discover() -> Result<Self, PathsError> {
213        #[cfg(target_os = "linux")]
214        if crate::host_fitness::is_elevated() {
215            return Ok(Self::for_root(Self::system(), Self::standard().ok()));
216        }
217        Self::standard()
218    }
219
220    /// `system`, unless only `earlier` exists yet.
221    ///
222    /// An installation from before the system tree keeps the tree it has -- a
223    /// WSL distribution's service among them -- until `service install
224    /// --start-at boot` moves it.
225    #[cfg(any(test, target_os = "linux"))]
226    fn for_root(system: Self, earlier: Option<Self>) -> Self {
227        match earlier {
228            Some(earlier) if !system.config.exists() && earlier.config.exists() => earlier,
229            _ => system,
230        }
231    }
232
233    /// The per-account locations in the module documentation's table.
234    fn standard() -> Result<Self, PathsError> {
235        let dirs = ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
236            .ok_or(PathsError::NoHomeDirectory)?;
237
238        // `state_dir` is `Some` on Linux only; see the module documentation.
239        let state = dirs
240            .state_dir()
241            .map_or_else(|| dirs.data_local_dir().join(STATE), Path::to_path_buf);
242
243        Ok(Self {
244            config: dirs.config_local_dir().to_path_buf(),
245            state,
246            runtime: dirs.data_local_dir().join(RUNTIME),
247            logs: dirs.data_local_dir().join(LOGS),
248        })
249    }
250
251    /// The tree `root` uses on Linux: [`SYSTEM_DATA_ROOT`] for the first three
252    /// and [`SYSTEM_LOGS_DIR`] for diagnostics. See the module documentation.
253    #[must_use]
254    pub fn system() -> Self {
255        let root = Path::new(SYSTEM_DATA_ROOT);
256        Self {
257            config: root.join(CONFIG),
258            state: root.join(STATE),
259            runtime: root.join(RUNTIME),
260            logs: PathBuf::from(SYSTEM_LOGS_DIR),
261        }
262    }
263
264    /// Places all four directories under one root, using the names
265    /// `05-infrastructure.md` gives them.
266    ///
267    /// This is how a test gets a disposable layout and how a service that was
268    /// installed against an explicitly configured root reproduces it. It is
269    /// deliberately *not* what [`AppPaths::discover`] falls back to: a relative
270    /// root passed here stays relative, and that is the caller's decision to
271    /// make rather than a default anything acquires by accident.
272    pub fn rooted_at(root: impl AsRef<Path>) -> Self {
273        let root = root.as_ref();
274        Self {
275            config: root.join(CONFIG),
276            state: root.join(STATE),
277            runtime: root.join(RUNTIME),
278            logs: root.join(LOGS),
279        }
280    }
281
282    /// Non-secret TOML configuration and the SQLite database.
283    #[must_use]
284    pub fn config_dir(&self) -> &Path {
285        &self.config
286    }
287
288    /// The agent lock, the attempt journal, and the retained runner package
289    /// cache.
290    #[must_use]
291    pub fn state_dir(&self) -> &Path {
292        &self.state
293    }
294
295    /// Per-attempt disposable runner workspaces.
296    #[must_use]
297    pub fn runtime_dir(&self) -> &Path {
298        &self.runtime
299    }
300
301    /// Rotating redacted agent diagnostics.
302    #[must_use]
303    pub fn logs_dir(&self) -> &Path {
304        &self.logs
305    }
306
307    /// The four directories, paired with the name each is known by. Ordered
308    /// as `05-infrastructure.md` lists them.
309    #[must_use]
310    pub fn all(&self) -> [(&'static str, &Path); 4] {
311        [
312            (CONFIG, self.config.as_path()),
313            (STATE, self.state.as_path()),
314            (RUNTIME, self.runtime.as_path()),
315            (LOGS, self.logs.as_path()),
316        ]
317    }
318
319    /// Creates every directory that does not already exist.
320    ///
321    /// Idempotent, and restrictive where the platform expresses that through
322    /// mode bits: on Unix each leaf is created *at* `0700`, so a runner
323    /// workspace, an attempt journal, and a diagnostics file are not readable
324    /// by other local accounts. Intermediate directories — `~/.local`,
325    /// `~/.config` — are left alone, because they are shared with every other
326    /// application and are not this program's to tighten.
327    ///
328    /// The mode is passed to `mkdir(2)` rather than applied with a following
329    /// `chmod`, for the same reason [`crate::process::RestrictiveHandoff`]
330    /// passes it to `open(2)`: the two-step version leaves a window in which
331    /// `state/` and `runtime/` exist at the umask default — typically `0755` —
332    /// and those are the directories holding the attempt journal and the runner
333    /// workspaces. A directory that was *already* there is still tightened,
334    /// which is what keeps this idempotent and what upgrades a tree created by
335    /// an earlier version.
336    ///
337    /// On Windows the per-account `AppData` tree already denies other
338    /// non-administrative users, and the one file whose exposure actually
339    /// matters gets an explicit DACL of its own rather than relying on that:
340    /// see [`crate::process::RestrictiveHandoff`].
341    ///
342    /// # Errors
343    ///
344    /// [`PathsError::Create`], naming which of the four failed and why.
345    pub fn create_all(&self) -> Result<(), PathsError> {
346        for (purpose, path) in self.all() {
347            let failed = |source| PathsError::Create {
348                purpose,
349                path: path.to_path_buf(),
350                source,
351            };
352
353            // The parents are created at whatever the umask says, deliberately:
354            // they are `~/.local` and its like, and are not this program's to
355            // tighten.
356            if let Some(parent) = path.parent() {
357                std::fs::create_dir_all(parent).map_err(failed)?;
358            }
359
360            match create_restricted_leaf(path) {
361                Ok(()) => {
362                    // `DirBuilder::mode` passes the mode through `mkdir(2)`,
363                    // which applies `& ~umask`. The result is therefore always
364                    // a *subset* of `0700` -- never more permissive, so this is
365                    // not a security hole -- but it is no longer exactly `0700`
366                    // the way an explicit `set_permissions` made it. An unusual
367                    // umask that strips owner bits would leave a tree this
368                    // program cannot write, and would fail the `0700` assertion
369                    // the logging installer makes about these directories.
370                    //
371                    // Chmodding the freshly-created path does not reopen the
372                    // window that creating-then-tightening used to have:
373                    // created at `0700` or tighter and then set to `0700`, the
374                    // directory is not permissive at any instant.
375                    restrict_directory(purpose, path)?;
376                }
377                Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
378                    // Already there. It may predate this rule, or predate this
379                    // program, so tighten it rather than assume. A non-
380                    // directory at the path is still an error, exactly as it
381                    // was when this used `create_dir_all`.
382                    if !path.is_dir() {
383                        return Err(failed(source));
384                    }
385                    restrict_directory(purpose, path)?;
386                }
387                Err(source) if source.kind() == std::io::ErrorKind::PermissionDenied => {
388                    // `mkdir(2)` reports `EACCES` about the parent: the leaf
389                    // does not exist yet, so it cannot be what refused. The
390                    // parent is known exactly here -- `create_dir_all` above
391                    // just returned `Ok` for it -- which is why this is the
392                    // one place the diagnosis can be made without guessing.
393                    let parent = path.parent().unwrap_or(path);
394                    return Err(PathsError::ParentDenies {
395                        purpose,
396                        path: path.to_path_buf(),
397                        parent_state: describe_parent(parent),
398                        source,
399                    });
400                }
401                Err(source) => return Err(failed(source)),
402            }
403        }
404        Ok(())
405    }
406}
407
408/// Creates one leaf directory with its final permissions already applied.
409///
410/// Fails with [`std::io::ErrorKind::AlreadyExists`] when anything is at the
411/// path; the caller decides what that means.
412#[cfg(unix)]
413fn create_restricted_leaf(path: &Path) -> std::io::Result<()> {
414    use std::os::unix::fs::DirBuilderExt;
415
416    std::fs::DirBuilder::new().mode(0o700).create(path)
417}
418
419/// Windows has no mode bits to set at creation; the per-account `AppData` tree
420/// already denies other non-administrative users, and the one file whose
421/// exposure matters carries its own DACL.
422#[cfg(not(unix))]
423fn create_restricted_leaf(path: &Path) -> std::io::Result<()> {
424    std::fs::DirBuilder::new().create(path)
425}
426
427/// How the parent of a leaf that could not be created presents to this account.
428///
429/// Reported rather than acted on. This is the directory the operator has to
430/// look at, and the whole point of naming it is that the message about the
431/// leaf sends them somewhere that does not exist yet.
432fn describe_parent(parent: &Path) -> String {
433    match std::fs::metadata(parent) {
434        Ok(metadata) if metadata.is_dir() => permission_summary(&metadata),
435        // Both of these contradict the caller's premise -- `create_dir_all`
436        // returned `Ok` for this path a moment ago -- so say so plainly rather
437        // than inventing a mode for something that is not a directory.
438        Ok(_) => "not a directory".to_string(),
439        Err(error) => format!("not inspectable ({error})"),
440    }
441}
442
443/// What the platform can say about a directory's permissions.
444#[cfg(unix)]
445fn permission_summary(metadata: &std::fs::Metadata) -> String {
446    use std::os::unix::fs::PermissionsExt;
447
448    format!("mode {:04o}", metadata.permissions().mode() & 0o7777)
449}
450
451/// Windows has no mode bits. The read-only attribute is the only thing
452/// `std::fs` exposes, and it is almost never the reason -- so say which of the
453/// two answers this is, rather than implying the attribute is the whole story.
454#[cfg(not(unix))]
455fn permission_summary(metadata: &std::fs::Metadata) -> String {
456    if metadata.permissions().readonly() {
457        "marked read-only".to_string()
458    } else {
459        "not marked read-only, so an access-control entry is what refused".to_string()
460    }
461}
462
463impl fmt::Display for AppPaths {
464    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
465        // Deliberately multi-line: this is what `host show` prints, and four
466        // long absolute paths on one line are unreadable.
467        for (name, path) in self.all() {
468            writeln!(f, "{name}/ {}", path.display())?;
469        }
470        Ok(())
471    }
472}
473
474/// Sets a directory to exactly `0700`.
475///
476/// Called on both paths, and for two different reasons. A directory that
477/// already existed may predate this rule and can be anything at all. A
478/// directory this program just created is already a subset of `0700`, because
479/// `mkdir(2)` applied the mode through the umask -- but a subset is not the
480/// same as exactly `0700`, and the callers of these directories assume the
481/// owner bits are present.
482#[cfg(unix)]
483fn restrict_directory(purpose: &'static str, path: &Path) -> Result<(), PathsError> {
484    use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
485
486    let metadata = std::fs::metadata(path).ok();
487    // chmod(2) needs ownership or CAP_FOWNER even when the mode is unchanged,
488    // so an already-correct mode is not re-set. For `runtime` `0750` is correct
489    // too: a Linux boot service gives its runner root that mode with the
490    // runners' group, so that their account can reach its own attempt and no
491    // other account can, and re-tightening it here would cut every running job
492    // off.
493    let correct = |mode: u32| mode == 0o700 || (purpose == RUNTIME && mode == 0o750);
494    if metadata
495        .as_ref()
496        .is_some_and(|metadata| correct(metadata.permissions().mode() & 0o7777))
497    {
498        return Ok(());
499    }
500    match std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) {
501        Ok(()) => Ok(()),
502        // Another account's directory -- one reached through `--data-dir`, say
503        // -- is not this process's to tighten. Its owner's own next command
504        // does that instead.
505        Err(source)
506            if source.kind() == std::io::ErrorKind::PermissionDenied
507                && metadata.is_some_and(|metadata| {
508                    // SAFETY: `geteuid` reads this process's own credentials,
509                    // takes no argument and cannot fail.
510                    metadata.uid() != unsafe { libc::geteuid() }
511                }) =>
512        {
513            Ok(())
514        }
515        Err(source) => Err(PathsError::Create {
516            purpose,
517            path: path.to_path_buf(),
518            source,
519        }),
520    }
521}
522
523// Returns `Result` only so that the two arms share one signature; the Windows
524// and macOS-without-mode-bits case has nothing to do here.
525#[cfg(not(unix))]
526fn restrict_directory(_purpose: &'static str, _path: &Path) -> Result<(), PathsError> {
527    Ok(())
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[cfg(unix)]
535    #[test]
536    fn root_uses_the_system_tree_outside_every_home_directory() {
537        let system = AppPaths::system();
538        for (_, path) in system.all() {
539            assert!(is_clean_absolute(path), "{}", path.display());
540            assert!(!path.starts_with("/home") && !path.starts_with("/root"));
541        }
542        assert_eq!(
543            system.config_dir(),
544            Path::new("/var/lib/runner-manager/config")
545        );
546        assert_eq!(system.logs_dir(), Path::new("/var/log/runner-manager"));
547    }
548
549    #[test]
550    fn root_keeps_an_earlier_tree_only_until_the_system_tree_exists() {
551        let host = tempfile::tempdir().unwrap();
552        let system = AppPaths::rooted_at(host.path().join("var"));
553        let earlier = AppPaths::rooted_at(host.path().join("root"));
554        assert_eq!(
555            AppPaths::for_root(system.clone(), Some(earlier.clone())),
556            system,
557            "a fresh host uses the system tree"
558        );
559        earlier.create_all().unwrap();
560        assert_eq!(
561            AppPaths::for_root(system.clone(), Some(earlier.clone())),
562            earlier,
563            "an earlier installation keeps its tree"
564        );
565        system.create_all().unwrap();
566        assert_eq!(AppPaths::for_root(system.clone(), Some(earlier)), system);
567        assert_eq!(AppPaths::for_root(system.clone(), None), system);
568    }
569
570    /// A directory another account owns is used as it is rather than refused:
571    /// a `--data-dir` tree another account owns is not this process's to chmod.
572    #[cfg(unix)]
573    #[test]
574    fn another_accounts_directory_is_not_a_failure_to_tighten() {
575        use std::os::unix::fs::PermissionsExt as _;
576
577        if crate::host_fitness::is_elevated() {
578            return; // Root may chmod anything, so there is no refusal to see.
579        }
580        // `/tmp` rather than `/`: macOS mounts `/` read-only, and chmod there
581        // fails with EROFS whoever owns it.
582        let foreign = Path::new("/tmp");
583        let before = std::fs::metadata(foreign).unwrap().permissions().mode();
584        restrict_directory("state", foreign).expect("another account's directory");
585        assert_eq!(
586            std::fs::metadata(foreign).unwrap().permissions().mode(),
587            before
588        );
589    }
590
591    /// True when a path has no `.` or `..` component and is absolute — the
592    /// shape a resolved application-data path must have before anything else
593    /// is worth asserting about it.
594    fn is_clean_absolute(path: &Path) -> bool {
595        use std::path::Component;
596        path.is_absolute()
597            && !path
598                .components()
599                .any(|c| matches!(c, Component::CurDir | Component::ParentDir))
600    }
601
602    /// Resolves with `resolve`, moves the process to `elsewhere`, resolves
603    /// again, and reports whether the two answers agreed.
604    ///
605    /// Written as a helper returning `Result` rather than as inline assertions
606    /// so that `the_independence_check_catches_a_cwd_relative_resolver` below
607    /// can point it at a resolver that is wrong in exactly the way this module
608    /// exists to prevent. A test that only ever sees the correct
609    /// implementation cannot tell a working check from a vacuous one.
610    fn check_cwd_independence(
611        resolve: impl Fn() -> AppPaths,
612        elsewhere: &Path,
613    ) -> Result<(), String> {
614        let original = std::env::current_dir().expect("a current directory");
615        let before = resolve();
616        std::env::set_current_dir(elsewhere).expect("can enter the temporary directory");
617        let after = resolve();
618        std::env::set_current_dir(&original).expect("can return to the original directory");
619
620        if before == after {
621            Ok(())
622        } else {
623            Err(format!(
624                "the resolved layout moved with the process: before={before:?} after={after:?}"
625            ))
626        }
627    }
628
629    #[test]
630    fn discover_returns_four_distinct_clean_absolute_paths() {
631        let paths = AppPaths::discover().expect("a home directory exists on every CI leg");
632
633        let mut seen: Vec<&Path> = Vec::new();
634        for (name, path) in paths.all() {
635            assert!(
636                is_clean_absolute(path),
637                "{name}/ resolved to {}, which is not a clean absolute path",
638                path.display()
639            );
640            assert!(
641                !seen.contains(&path),
642                "{name}/ collides with another of the four: {}",
643                path.display()
644            );
645            seen.push(path);
646        }
647        assert_eq!(seen.len(), 4);
648    }
649
650    #[test]
651    #[serial_test::serial(current_dir)]
652    fn discover_does_not_move_with_the_process() {
653        let elsewhere = tempfile::tempdir().expect("a temporary directory");
654
655        check_cwd_independence(
656            || AppPaths::discover().expect("a home directory exists"),
657            elsewhere.path(),
658        )
659        .expect("the platform-standard layout must not depend on the current directory");
660    }
661
662    #[test]
663    #[serial_test::serial(current_dir)]
664    fn the_independence_check_catches_a_cwd_relative_resolver() {
665        let elsewhere = tempfile::tempdir().expect("a temporary directory");
666
667        // The exact defect `05-infrastructure.md` forbids: runner material
668        // landing wherever the daemon happened to be launched from.
669        let cwd_relative =
670            || AppPaths::rooted_at(std::env::current_dir().expect("a current directory"));
671
672        let complaint = check_cwd_independence(cwd_relative, elsewhere.path())
673            .expect_err("a current-directory-relative layout must be caught");
674        assert!(
675            complaint.contains("moved with the process"),
676            "the complaint must name the failure mode, got: {complaint}"
677        );
678    }
679
680    /// On all three platforms the standard locations live under the account's
681    /// home directory — `%LOCALAPPDATA%` on Windows, `~/Library` on macOS,
682    /// `~/.config` and `~/.local` on Linux. Asserting that is not the same as
683    /// asserting the path equals what `directories` returned, which would only
684    /// restate the implementation.
685    #[test]
686    fn discover_stays_under_the_account_home_directory() {
687        // An operator who has repointed an XDG base directory has deliberately
688        // moved it out of `$HOME`, and honouring that is the correct behaviour
689        // rather than a violation. Skip rather than fail in that case.
690        let overridden = ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"]
691            .iter()
692            .any(|name| std::env::var_os(name).is_some());
693        if overridden {
694            return;
695        }
696
697        let home = directories::BaseDirs::new()
698            .expect("a home directory exists on every CI leg")
699            .home_dir()
700            .to_path_buf();
701        let paths = AppPaths::discover().expect("a home directory exists");
702
703        for (name, path) in paths.all() {
704            assert!(
705                path.starts_with(&home),
706                "{name}/ resolved to {}, which is outside the account home {}",
707                path.display(),
708                home.display()
709            );
710            assert_ne!(
711                path,
712                home.as_path(),
713                "{name}/ must be a directory of this application's own, not the home directory"
714            );
715        }
716    }
717
718    #[test]
719    fn rooted_at_produces_the_layout_the_infrastructure_document_names() {
720        let root = Path::new("/srv/runner-manager");
721        let paths = AppPaths::rooted_at(root);
722
723        assert_eq!(paths.config_dir(), root.join("config"));
724        assert_eq!(paths.state_dir(), root.join("state"));
725        assert_eq!(paths.runtime_dir(), root.join("runtime"));
726        assert_eq!(paths.logs_dir(), root.join("logs"));
727
728        assert_eq!(
729            paths.all().map(|(name, _)| name),
730            ["config", "state", "runtime", "logs"],
731            "the order and the names are what `host show` prints"
732        );
733    }
734
735    /// A denied creation must name the directory that actually refused.
736    ///
737    /// The diagnosis, not the policy. `create_all` still fails, and still
738    /// leaves the parent alone -- an intermediate directory is shared with
739    /// every other application and is not the agent's to tighten. What
740    /// changes is that the message stops sending the operator to a path that
741    /// does not exist.
742    #[cfg(unix)]
743    #[test]
744    fn a_denied_creation_names_the_parent_rather_than_the_leaf() {
745        use std::os::unix::fs::PermissionsExt;
746
747        let root = tempfile::tempdir().expect("a temporary directory");
748        let locked = root.path().join("locked");
749        std::fs::create_dir(&locked).expect("the parent is created writable");
750        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555))
751            .expect("the parent is made unwritable");
752
753        // Root ignores the mode bits, and containers routinely run as root.
754        // Probe rather than ask: if a child can still be created here, this
755        // account is not the one the test needs, and skipping is honest where
756        // failing would be a lie about the code.
757        let probe = locked.join("probe");
758        let skip = std::fs::create_dir(&probe).is_ok();
759        if skip {
760            std::fs::remove_dir(&probe).expect("the probe is removed");
761        }
762        // Restored before any assertion can unwind past it, or the temporary
763        // directory cannot be cleaned up.
764        let restore = || {
765            std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).ok();
766        };
767        if skip {
768            restore();
769            return;
770        }
771
772        // Rooted *at* the unwritable directory, so that the leaf's parent
773        // already exists and the refusal comes from `create_restricted_leaf`
774        // rather than from the `create_dir_all` above it.
775        let paths = AppPaths::rooted_at(&locked);
776        let outcome = paths.create_all();
777        restore();
778
779        let error = outcome.expect_err("an unwritable parent must not succeed");
780        let PathsError::ParentDenies {
781            purpose,
782            path,
783            parent_state,
784            ..
785        } = &error
786        else {
787            panic!("a denied creation must be reported as such, not as a bare Create: {error}");
788        };
789
790        assert_eq!(*purpose, "config", "the first of the four is what failed");
791        assert_eq!(path, &locked.join("config"));
792        assert_eq!(
793            path.parent(),
794            Some(locked.as_path()),
795            "the parent is the directory that refused"
796        );
797        assert_eq!(parent_state, "mode 0555", "{parent_state}");
798
799        // The message is the whole point of the variant, so assert on it.
800        let message = error.to_string();
801        assert!(
802            message.contains(&locked.display().to_string()),
803            "the parent must be named: {message}"
804        );
805        assert!(
806            message.contains("mode 0555"),
807            "the parent's state must be given, or the operator has to go and \
808             look it up before they can act: {message}"
809        );
810        assert!(
811            message.contains("not the obstacle"),
812            "the message must say why the leaf is not the thing to look at, \
813             or naming the parent reads as an aside: {message}"
814        );
815
816        // The policy is unchanged: nothing was widened on the way out.
817        let mode = std::fs::metadata(&locked)
818            .expect("the parent still exists")
819            .permissions()
820            .mode()
821            & 0o777;
822        assert_eq!(
823            mode, 0o755,
824            "only this test's own restore may have touched the parent"
825        );
826    }
827
828    #[test]
829    fn create_all_is_idempotent() {
830        let root = tempfile::tempdir().expect("a temporary directory");
831        let paths = AppPaths::rooted_at(root.path());
832
833        paths.create_all().expect("first creation succeeds");
834        // A daemon calls this on every start, including the ones where the
835        // directories are already there.
836        paths.create_all().expect("second creation succeeds");
837
838        for (name, path) in paths.all() {
839            assert!(
840                path.is_dir(),
841                "{name}/ was not created at {}",
842                path.display()
843            );
844        }
845    }
846
847    #[cfg(unix)]
848    #[test]
849    fn create_all_leaves_no_directory_readable_by_other_accounts() {
850        use std::os::unix::fs::PermissionsExt;
851
852        let root = tempfile::tempdir().expect("a temporary directory");
853        let paths = AppPaths::rooted_at(root.path());
854        paths.create_all().expect("creation succeeds");
855
856        for (name, path) in paths.all() {
857            let mode = std::fs::metadata(path)
858                .expect("the directory exists")
859                .permissions()
860                .mode()
861                & 0o777;
862            assert_eq!(
863                mode, 0o700,
864                "{name}/ is mode {mode:o}; group and other must have no access at all, \
865                 because the attempt journal and the runner workspaces live here"
866            );
867        }
868    }
869
870    #[test]
871    fn display_lists_all_four_directories() {
872        let paths = AppPaths::rooted_at(Path::new("/srv/runner-manager"));
873        let rendered = paths.to_string();
874
875        for name in ["config/", "state/", "runtime/", "logs/"] {
876            assert!(rendered.contains(name), "{name} missing from:\n{rendered}");
877        }
878        assert_eq!(rendered.lines().count(), 4);
879    }
880}