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}