use std::fmt;
use std::path::{Path, PathBuf};
use directories::ProjectDirs;
pub(crate) const QUALIFIER: &str = "io.github";
pub(crate) const ORGANIZATION: &str = "IvanMurzak";
pub(crate) const APPLICATION: &str = "runner-manager";
const CONFIG: &str = "config";
const STATE: &str = "state";
const RUNTIME: &str = "runtime";
const LOGS: &str = "logs";
#[derive(Debug, thiserror::Error)]
pub enum PathsError {
#[error(
"cannot determine a home directory for this account, so the platform-standard \
application-data directories cannot be resolved. A service account normally hits \
this when it is configured with no profile; give the account a home directory, or \
run the agent against an explicit root."
)]
NoHomeDirectory,
#[error("cannot create the {purpose} directory {}: {source}", path.display())]
Create {
purpose: &'static str,
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"cannot create the {purpose} directory {}: permission denied. The directory named is \
not the obstacle -- it does not exist yet, so it cannot be what refused; the refusal \
comes from its parent. {} is {parent_state}. This program will not change it: an \
intermediate directory is shared with every other application and is not the agent's \
to tighten. Grant this account write access to that directory, or run the agent \
against an explicit root under a directory it owns.",
path.display(),
path.parent().unwrap_or(path.as_path()).display()
)]
ParentDenies {
purpose: &'static str,
path: PathBuf,
parent_state: String,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppPaths {
config: PathBuf,
state: PathBuf,
runtime: PathBuf,
logs: PathBuf,
}
impl AppPaths {
#[must_use]
pub fn from_directories(
config: impl Into<PathBuf>,
state: impl Into<PathBuf>,
runtime: impl Into<PathBuf>,
logs: impl Into<PathBuf>,
) -> Self {
Self {
config: config.into(),
state: state.into(),
runtime: runtime.into(),
logs: logs.into(),
}
}
pub fn discover() -> Result<Self, PathsError> {
let dirs = ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
.ok_or(PathsError::NoHomeDirectory)?;
let state = dirs
.state_dir()
.map_or_else(|| dirs.data_local_dir().join(STATE), Path::to_path_buf);
Ok(Self {
config: dirs.config_local_dir().to_path_buf(),
state,
runtime: dirs.data_local_dir().join(RUNTIME),
logs: dirs.data_local_dir().join(LOGS),
})
}
pub fn rooted_at(root: impl AsRef<Path>) -> Self {
let root = root.as_ref();
Self {
config: root.join(CONFIG),
state: root.join(STATE),
runtime: root.join(RUNTIME),
logs: root.join(LOGS),
}
}
#[must_use]
pub fn config_dir(&self) -> &Path {
&self.config
}
#[must_use]
pub fn state_dir(&self) -> &Path {
&self.state
}
#[must_use]
pub fn runtime_dir(&self) -> &Path {
&self.runtime
}
#[must_use]
pub fn logs_dir(&self) -> &Path {
&self.logs
}
#[must_use]
pub fn all(&self) -> [(&'static str, &Path); 4] {
[
(CONFIG, self.config.as_path()),
(STATE, self.state.as_path()),
(RUNTIME, self.runtime.as_path()),
(LOGS, self.logs.as_path()),
]
}
pub fn create_all(&self) -> Result<(), PathsError> {
for (purpose, path) in self.all() {
let failed = |source| PathsError::Create {
purpose,
path: path.to_path_buf(),
source,
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(failed)?;
}
match create_restricted_leaf(path) {
Ok(()) => {
restrict_directory(purpose, path)?;
}
Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
if !path.is_dir() {
return Err(failed(source));
}
restrict_directory(purpose, path)?;
}
Err(source) if source.kind() == std::io::ErrorKind::PermissionDenied => {
let parent = path.parent().unwrap_or(path);
return Err(PathsError::ParentDenies {
purpose,
path: path.to_path_buf(),
parent_state: describe_parent(parent),
source,
});
}
Err(source) => return Err(failed(source)),
}
}
Ok(())
}
}
#[cfg(unix)]
fn create_restricted_leaf(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new().mode(0o700).create(path)
}
#[cfg(not(unix))]
fn create_restricted_leaf(path: &Path) -> std::io::Result<()> {
std::fs::DirBuilder::new().create(path)
}
fn describe_parent(parent: &Path) -> String {
match std::fs::metadata(parent) {
Ok(metadata) if metadata.is_dir() => permission_summary(&metadata),
Ok(_) => "not a directory".to_string(),
Err(error) => format!("not inspectable ({error})"),
}
}
#[cfg(unix)]
fn permission_summary(metadata: &std::fs::Metadata) -> String {
use std::os::unix::fs::PermissionsExt;
format!("mode {:04o}", metadata.permissions().mode() & 0o7777)
}
#[cfg(not(unix))]
fn permission_summary(metadata: &std::fs::Metadata) -> String {
if metadata.permissions().readonly() {
"marked read-only".to_string()
} else {
"not marked read-only, so an access-control entry is what refused".to_string()
}
}
impl fmt::Display for AppPaths {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (name, path) in self.all() {
writeln!(f, "{name}/ {}", path.display())?;
}
Ok(())
}
}
#[cfg(unix)]
fn restrict_directory(purpose: &'static str, path: &Path) -> Result<(), PathsError> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|source| {
PathsError::Create {
purpose,
path: path.to_path_buf(),
source,
}
})
}
#[cfg(not(unix))]
fn restrict_directory(_purpose: &'static str, _path: &Path) -> Result<(), PathsError> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn is_clean_absolute(path: &Path) -> bool {
use std::path::Component;
path.is_absolute()
&& !path
.components()
.any(|c| matches!(c, Component::CurDir | Component::ParentDir))
}
fn check_cwd_independence(
resolve: impl Fn() -> AppPaths,
elsewhere: &Path,
) -> Result<(), String> {
let original = std::env::current_dir().expect("a current directory");
let before = resolve();
std::env::set_current_dir(elsewhere).expect("can enter the temporary directory");
let after = resolve();
std::env::set_current_dir(&original).expect("can return to the original directory");
if before == after {
Ok(())
} else {
Err(format!(
"the resolved layout moved with the process: before={before:?} after={after:?}"
))
}
}
#[test]
fn discover_returns_four_distinct_clean_absolute_paths() {
let paths = AppPaths::discover().expect("a home directory exists on every CI leg");
let mut seen: Vec<&Path> = Vec::new();
for (name, path) in paths.all() {
assert!(
is_clean_absolute(path),
"{name}/ resolved to {}, which is not a clean absolute path",
path.display()
);
assert!(
!seen.contains(&path),
"{name}/ collides with another of the four: {}",
path.display()
);
seen.push(path);
}
assert_eq!(seen.len(), 4);
}
#[test]
#[serial_test::serial(current_dir)]
fn discover_does_not_move_with_the_process() {
let elsewhere = tempfile::tempdir().expect("a temporary directory");
check_cwd_independence(
|| AppPaths::discover().expect("a home directory exists"),
elsewhere.path(),
)
.expect("the platform-standard layout must not depend on the current directory");
}
#[test]
#[serial_test::serial(current_dir)]
fn the_independence_check_catches_a_cwd_relative_resolver() {
let elsewhere = tempfile::tempdir().expect("a temporary directory");
let cwd_relative =
|| AppPaths::rooted_at(std::env::current_dir().expect("a current directory"));
let complaint = check_cwd_independence(cwd_relative, elsewhere.path())
.expect_err("a current-directory-relative layout must be caught");
assert!(
complaint.contains("moved with the process"),
"the complaint must name the failure mode, got: {complaint}"
);
}
#[test]
fn discover_stays_under_the_account_home_directory() {
let overridden = ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"]
.iter()
.any(|name| std::env::var_os(name).is_some());
if overridden {
return;
}
let home = directories::BaseDirs::new()
.expect("a home directory exists on every CI leg")
.home_dir()
.to_path_buf();
let paths = AppPaths::discover().expect("a home directory exists");
for (name, path) in paths.all() {
assert!(
path.starts_with(&home),
"{name}/ resolved to {}, which is outside the account home {}",
path.display(),
home.display()
);
assert_ne!(
path,
home.as_path(),
"{name}/ must be a directory of this application's own, not the home directory"
);
}
}
#[test]
fn rooted_at_produces_the_layout_the_infrastructure_document_names() {
let root = Path::new("/srv/runner-manager");
let paths = AppPaths::rooted_at(root);
assert_eq!(paths.config_dir(), root.join("config"));
assert_eq!(paths.state_dir(), root.join("state"));
assert_eq!(paths.runtime_dir(), root.join("runtime"));
assert_eq!(paths.logs_dir(), root.join("logs"));
assert_eq!(
paths.all().map(|(name, _)| name),
["config", "state", "runtime", "logs"],
"the order and the names are what `host show` prints"
);
}
#[cfg(unix)]
#[test]
fn a_denied_creation_names_the_parent_rather_than_the_leaf() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().expect("a temporary directory");
let locked = root.path().join("locked");
std::fs::create_dir(&locked).expect("the parent is created writable");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555))
.expect("the parent is made unwritable");
let probe = locked.join("probe");
let skip = std::fs::create_dir(&probe).is_ok();
if skip {
std::fs::remove_dir(&probe).expect("the probe is removed");
}
let restore = || {
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).ok();
};
if skip {
restore();
return;
}
let paths = AppPaths::rooted_at(&locked);
let outcome = paths.create_all();
restore();
let error = outcome.expect_err("an unwritable parent must not succeed");
let PathsError::ParentDenies {
purpose,
path,
parent_state,
..
} = &error
else {
panic!("a denied creation must be reported as such, not as a bare Create: {error}");
};
assert_eq!(*purpose, "config", "the first of the four is what failed");
assert_eq!(path, &locked.join("config"));
assert_eq!(
path.parent(),
Some(locked.as_path()),
"the parent is the directory that refused"
);
assert_eq!(parent_state, "mode 0555", "{parent_state}");
let message = error.to_string();
assert!(
message.contains(&locked.display().to_string()),
"the parent must be named: {message}"
);
assert!(
message.contains("mode 0555"),
"the parent's state must be given, or the operator has to go and \
look it up before they can act: {message}"
);
assert!(
message.contains("not the obstacle"),
"the message must say why the leaf is not the thing to look at, \
or naming the parent reads as an aside: {message}"
);
let mode = std::fs::metadata(&locked)
.expect("the parent still exists")
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, 0o755,
"only this test's own restore may have touched the parent"
);
}
#[test]
fn create_all_is_idempotent() {
let root = tempfile::tempdir().expect("a temporary directory");
let paths = AppPaths::rooted_at(root.path());
paths.create_all().expect("first creation succeeds");
paths.create_all().expect("second creation succeeds");
for (name, path) in paths.all() {
assert!(
path.is_dir(),
"{name}/ was not created at {}",
path.display()
);
}
}
#[cfg(unix)]
#[test]
fn create_all_leaves_no_directory_readable_by_other_accounts() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().expect("a temporary directory");
let paths = AppPaths::rooted_at(root.path());
paths.create_all().expect("creation succeeds");
for (name, path) in paths.all() {
let mode = std::fs::metadata(path)
.expect("the directory exists")
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, 0o700,
"{name}/ is mode {mode:o}; group and other must have no access at all, \
because the attempt journal and the runner workspaces live here"
);
}
}
#[test]
fn display_lists_all_four_directories() {
let paths = AppPaths::rooted_at(Path::new("/srv/runner-manager"));
let rendered = paths.to_string();
for name in ["config/", "state/", "runtime/", "logs/"] {
assert!(rendered.contains(name), "{name} missing from:\n{rendered}");
}
assert_eq!(rendered.lines().count(), 4);
}
}