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.
489 if metadata
490 .as_ref()
491 .is_some_and(|metadata| metadata.permissions().mode() & 0o7777 == 0o700)
492 {
493 return Ok(());
494 }
495 match std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) {
496 Ok(()) => Ok(()),
497 // Another account's directory -- one reached through `--data-dir`, say
498 // -- is not this process's to tighten. Its owner's own next command
499 // does that instead.
500 Err(source)
501 if source.kind() == std::io::ErrorKind::PermissionDenied
502 && metadata.is_some_and(|metadata| {
503 // SAFETY: `geteuid` reads this process's own credentials,
504 // takes no argument and cannot fail.
505 metadata.uid() != unsafe { libc::geteuid() }
506 }) =>
507 {
508 Ok(())
509 }
510 Err(source) => Err(PathsError::Create {
511 purpose,
512 path: path.to_path_buf(),
513 source,
514 }),
515 }
516}
517
518// Returns `Result` only so that the two arms share one signature; the Windows
519// and macOS-without-mode-bits case has nothing to do here.
520#[cfg(not(unix))]
521fn restrict_directory(_purpose: &'static str, _path: &Path) -> Result<(), PathsError> {
522 Ok(())
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[cfg(unix)]
530 #[test]
531 fn root_uses_the_system_tree_outside_every_home_directory() {
532 let system = AppPaths::system();
533 for (_, path) in system.all() {
534 assert!(is_clean_absolute(path), "{}", path.display());
535 assert!(!path.starts_with("/home") && !path.starts_with("/root"));
536 }
537 assert_eq!(
538 system.config_dir(),
539 Path::new("/var/lib/runner-manager/config")
540 );
541 assert_eq!(system.logs_dir(), Path::new("/var/log/runner-manager"));
542 }
543
544 #[test]
545 fn root_keeps_an_earlier_tree_only_until_the_system_tree_exists() {
546 let host = tempfile::tempdir().unwrap();
547 let system = AppPaths::rooted_at(host.path().join("var"));
548 let earlier = AppPaths::rooted_at(host.path().join("root"));
549 assert_eq!(
550 AppPaths::for_root(system.clone(), Some(earlier.clone())),
551 system,
552 "a fresh host uses the system tree"
553 );
554 earlier.create_all().unwrap();
555 assert_eq!(
556 AppPaths::for_root(system.clone(), Some(earlier.clone())),
557 earlier,
558 "an earlier installation keeps its tree"
559 );
560 system.create_all().unwrap();
561 assert_eq!(AppPaths::for_root(system.clone(), Some(earlier)), system);
562 assert_eq!(AppPaths::for_root(system.clone(), None), system);
563 }
564
565 /// A directory another account owns is used as it is rather than refused:
566 /// a `--data-dir` tree another account owns is not this process's to chmod.
567 #[cfg(unix)]
568 #[test]
569 fn another_accounts_directory_is_not_a_failure_to_tighten() {
570 use std::os::unix::fs::PermissionsExt as _;
571
572 if crate::host_fitness::is_elevated() {
573 return; // Root may chmod anything, so there is no refusal to see.
574 }
575 // `/tmp` rather than `/`: macOS mounts `/` read-only, and chmod there
576 // fails with EROFS whoever owns it.
577 let foreign = Path::new("/tmp");
578 let before = std::fs::metadata(foreign).unwrap().permissions().mode();
579 restrict_directory("state", foreign).expect("another account's directory");
580 assert_eq!(
581 std::fs::metadata(foreign).unwrap().permissions().mode(),
582 before
583 );
584 }
585
586 /// True when a path has no `.` or `..` component and is absolute — the
587 /// shape a resolved application-data path must have before anything else
588 /// is worth asserting about it.
589 fn is_clean_absolute(path: &Path) -> bool {
590 use std::path::Component;
591 path.is_absolute()
592 && !path
593 .components()
594 .any(|c| matches!(c, Component::CurDir | Component::ParentDir))
595 }
596
597 /// Resolves with `resolve`, moves the process to `elsewhere`, resolves
598 /// again, and reports whether the two answers agreed.
599 ///
600 /// Written as a helper returning `Result` rather than as inline assertions
601 /// so that `the_independence_check_catches_a_cwd_relative_resolver` below
602 /// can point it at a resolver that is wrong in exactly the way this module
603 /// exists to prevent. A test that only ever sees the correct
604 /// implementation cannot tell a working check from a vacuous one.
605 fn check_cwd_independence(
606 resolve: impl Fn() -> AppPaths,
607 elsewhere: &Path,
608 ) -> Result<(), String> {
609 let original = std::env::current_dir().expect("a current directory");
610 let before = resolve();
611 std::env::set_current_dir(elsewhere).expect("can enter the temporary directory");
612 let after = resolve();
613 std::env::set_current_dir(&original).expect("can return to the original directory");
614
615 if before == after {
616 Ok(())
617 } else {
618 Err(format!(
619 "the resolved layout moved with the process: before={before:?} after={after:?}"
620 ))
621 }
622 }
623
624 #[test]
625 fn discover_returns_four_distinct_clean_absolute_paths() {
626 let paths = AppPaths::discover().expect("a home directory exists on every CI leg");
627
628 let mut seen: Vec<&Path> = Vec::new();
629 for (name, path) in paths.all() {
630 assert!(
631 is_clean_absolute(path),
632 "{name}/ resolved to {}, which is not a clean absolute path",
633 path.display()
634 );
635 assert!(
636 !seen.contains(&path),
637 "{name}/ collides with another of the four: {}",
638 path.display()
639 );
640 seen.push(path);
641 }
642 assert_eq!(seen.len(), 4);
643 }
644
645 #[test]
646 #[serial_test::serial(current_dir)]
647 fn discover_does_not_move_with_the_process() {
648 let elsewhere = tempfile::tempdir().expect("a temporary directory");
649
650 check_cwd_independence(
651 || AppPaths::discover().expect("a home directory exists"),
652 elsewhere.path(),
653 )
654 .expect("the platform-standard layout must not depend on the current directory");
655 }
656
657 #[test]
658 #[serial_test::serial(current_dir)]
659 fn the_independence_check_catches_a_cwd_relative_resolver() {
660 let elsewhere = tempfile::tempdir().expect("a temporary directory");
661
662 // The exact defect `05-infrastructure.md` forbids: runner material
663 // landing wherever the daemon happened to be launched from.
664 let cwd_relative =
665 || AppPaths::rooted_at(std::env::current_dir().expect("a current directory"));
666
667 let complaint = check_cwd_independence(cwd_relative, elsewhere.path())
668 .expect_err("a current-directory-relative layout must be caught");
669 assert!(
670 complaint.contains("moved with the process"),
671 "the complaint must name the failure mode, got: {complaint}"
672 );
673 }
674
675 /// On all three platforms the standard locations live under the account's
676 /// home directory — `%LOCALAPPDATA%` on Windows, `~/Library` on macOS,
677 /// `~/.config` and `~/.local` on Linux. Asserting that is not the same as
678 /// asserting the path equals what `directories` returned, which would only
679 /// restate the implementation.
680 #[test]
681 fn discover_stays_under_the_account_home_directory() {
682 // An operator who has repointed an XDG base directory has deliberately
683 // moved it out of `$HOME`, and honouring that is the correct behaviour
684 // rather than a violation. Skip rather than fail in that case.
685 let overridden = ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"]
686 .iter()
687 .any(|name| std::env::var_os(name).is_some());
688 if overridden {
689 return;
690 }
691
692 let home = directories::BaseDirs::new()
693 .expect("a home directory exists on every CI leg")
694 .home_dir()
695 .to_path_buf();
696 let paths = AppPaths::discover().expect("a home directory exists");
697
698 for (name, path) in paths.all() {
699 assert!(
700 path.starts_with(&home),
701 "{name}/ resolved to {}, which is outside the account home {}",
702 path.display(),
703 home.display()
704 );
705 assert_ne!(
706 path,
707 home.as_path(),
708 "{name}/ must be a directory of this application's own, not the home directory"
709 );
710 }
711 }
712
713 #[test]
714 fn rooted_at_produces_the_layout_the_infrastructure_document_names() {
715 let root = Path::new("/srv/runner-manager");
716 let paths = AppPaths::rooted_at(root);
717
718 assert_eq!(paths.config_dir(), root.join("config"));
719 assert_eq!(paths.state_dir(), root.join("state"));
720 assert_eq!(paths.runtime_dir(), root.join("runtime"));
721 assert_eq!(paths.logs_dir(), root.join("logs"));
722
723 assert_eq!(
724 paths.all().map(|(name, _)| name),
725 ["config", "state", "runtime", "logs"],
726 "the order and the names are what `host show` prints"
727 );
728 }
729
730 /// A denied creation must name the directory that actually refused.
731 ///
732 /// The diagnosis, not the policy. `create_all` still fails, and still
733 /// leaves the parent alone -- an intermediate directory is shared with
734 /// every other application and is not the agent's to tighten. What
735 /// changes is that the message stops sending the operator to a path that
736 /// does not exist.
737 #[cfg(unix)]
738 #[test]
739 fn a_denied_creation_names_the_parent_rather_than_the_leaf() {
740 use std::os::unix::fs::PermissionsExt;
741
742 let root = tempfile::tempdir().expect("a temporary directory");
743 let locked = root.path().join("locked");
744 std::fs::create_dir(&locked).expect("the parent is created writable");
745 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555))
746 .expect("the parent is made unwritable");
747
748 // Root ignores the mode bits, and containers routinely run as root.
749 // Probe rather than ask: if a child can still be created here, this
750 // account is not the one the test needs, and skipping is honest where
751 // failing would be a lie about the code.
752 let probe = locked.join("probe");
753 let skip = std::fs::create_dir(&probe).is_ok();
754 if skip {
755 std::fs::remove_dir(&probe).expect("the probe is removed");
756 }
757 // Restored before any assertion can unwind past it, or the temporary
758 // directory cannot be cleaned up.
759 let restore = || {
760 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).ok();
761 };
762 if skip {
763 restore();
764 return;
765 }
766
767 // Rooted *at* the unwritable directory, so that the leaf's parent
768 // already exists and the refusal comes from `create_restricted_leaf`
769 // rather than from the `create_dir_all` above it.
770 let paths = AppPaths::rooted_at(&locked);
771 let outcome = paths.create_all();
772 restore();
773
774 let error = outcome.expect_err("an unwritable parent must not succeed");
775 let PathsError::ParentDenies {
776 purpose,
777 path,
778 parent_state,
779 ..
780 } = &error
781 else {
782 panic!("a denied creation must be reported as such, not as a bare Create: {error}");
783 };
784
785 assert_eq!(*purpose, "config", "the first of the four is what failed");
786 assert_eq!(path, &locked.join("config"));
787 assert_eq!(
788 path.parent(),
789 Some(locked.as_path()),
790 "the parent is the directory that refused"
791 );
792 assert_eq!(parent_state, "mode 0555", "{parent_state}");
793
794 // The message is the whole point of the variant, so assert on it.
795 let message = error.to_string();
796 assert!(
797 message.contains(&locked.display().to_string()),
798 "the parent must be named: {message}"
799 );
800 assert!(
801 message.contains("mode 0555"),
802 "the parent's state must be given, or the operator has to go and \
803 look it up before they can act: {message}"
804 );
805 assert!(
806 message.contains("not the obstacle"),
807 "the message must say why the leaf is not the thing to look at, \
808 or naming the parent reads as an aside: {message}"
809 );
810
811 // The policy is unchanged: nothing was widened on the way out.
812 let mode = std::fs::metadata(&locked)
813 .expect("the parent still exists")
814 .permissions()
815 .mode()
816 & 0o777;
817 assert_eq!(
818 mode, 0o755,
819 "only this test's own restore may have touched the parent"
820 );
821 }
822
823 #[test]
824 fn create_all_is_idempotent() {
825 let root = tempfile::tempdir().expect("a temporary directory");
826 let paths = AppPaths::rooted_at(root.path());
827
828 paths.create_all().expect("first creation succeeds");
829 // A daemon calls this on every start, including the ones where the
830 // directories are already there.
831 paths.create_all().expect("second creation succeeds");
832
833 for (name, path) in paths.all() {
834 assert!(
835 path.is_dir(),
836 "{name}/ was not created at {}",
837 path.display()
838 );
839 }
840 }
841
842 #[cfg(unix)]
843 #[test]
844 fn create_all_leaves_no_directory_readable_by_other_accounts() {
845 use std::os::unix::fs::PermissionsExt;
846
847 let root = tempfile::tempdir().expect("a temporary directory");
848 let paths = AppPaths::rooted_at(root.path());
849 paths.create_all().expect("creation succeeds");
850
851 for (name, path) in paths.all() {
852 let mode = std::fs::metadata(path)
853 .expect("the directory exists")
854 .permissions()
855 .mode()
856 & 0o777;
857 assert_eq!(
858 mode, 0o700,
859 "{name}/ is mode {mode:o}; group and other must have no access at all, \
860 because the attempt journal and the runner workspaces live here"
861 );
862 }
863 }
864
865 #[test]
866 fn display_lists_all_four_directories() {
867 let paths = AppPaths::rooted_at(Path::new("/srv/runner-manager"));
868 let rendered = paths.to_string();
869
870 for name in ["config/", "state/", "runtime/", "logs/"] {
871 assert!(rendered.contains(name), "{name} missing from:\n{rendered}");
872 }
873 assert_eq!(rendered.lines().count(), 4);
874 }
875}