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