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