#![allow(dead_code, clippy::missing_const_for_fn)]
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const RUNTIME_DIR_ENV: &str = "AUTUMN_RUNTIME_DIR";
const QUALIFIER: &str = "dev";
const ORGANIZATION: &str = "autumn";
const MAX_UNIX_SOCKET_PATH: usize = 100;
#[derive(Debug, thiserror::Error)]
pub enum PathsError {
#[error("could not determine a platform data directory for this user")]
NoPlatformDir,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimePaths {
runtime: PathBuf,
data: PathBuf,
logs: PathBuf,
socket: PathBuf,
}
fn resolve_socket_path(runtime: &Path, project: &str) -> PathBuf {
let natural = runtime.join("serve.sock");
if natural.as_os_str().len() <= MAX_UNIX_SOCKET_PATH {
return natural;
}
short_socket_root()
.join(format!("autumn-{}", short_project_id(project)))
.join("s.sock")
}
fn short_project_id(project: &str) -> String {
use sha2::{Digest, Sha256};
hex::encode(&Sha256::digest(project.as_bytes())[..4])
}
fn short_socket_root() -> PathBuf {
std::env::var_os("XDG_RUNTIME_DIR").map_or_else(std::env::temp_dir, PathBuf::from)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeParts {
pub runtime: PathBuf,
pub data: PathBuf,
pub logs: PathBuf,
pub socket: PathBuf,
}
impl RuntimePaths {
#[must_use]
pub fn parts(&self) -> RuntimeParts {
RuntimeParts {
runtime: self.runtime.clone(),
data: self.data.clone(),
logs: self.logs.clone(),
socket: self.socket.clone(),
}
}
#[must_use]
pub fn from_parts(parts: RuntimeParts) -> Self {
Self {
runtime: parts.runtime,
data: parts.data,
logs: parts.logs,
socket: parts.socket,
}
}
pub fn resolve(project: &str) -> Result<Self, PathsError> {
if let Some(base) = std::env::var_os(RUNTIME_DIR_ENV) {
let base = PathBuf::from(base);
let base = match (base.is_absolute(), std::env::current_dir()) {
(false, Ok(cwd)) => cwd.join(&base),
_ => base,
};
return Ok(Self::from_base(&base, project));
}
let dirs = directories::ProjectDirs::from(QUALIFIER, ORGANIZATION, project)
.ok_or(PathsError::NoPlatformDir)?;
let base = if cfg!(windows) {
dirs.data_local_dir()
} else {
dirs.data_dir()
};
let runtime = dirs
.runtime_dir()
.map_or_else(|| base.join("run"), Path::to_path_buf);
let data = base.to_path_buf();
let logs = dirs
.state_dir()
.map_or_else(|| base.join("logs"), |s| s.join("logs"));
let socket = resolve_socket_path(&runtime, project);
Ok(Self {
runtime,
data,
logs,
socket,
})
}
#[must_use]
pub fn from_base(base: &Path, project: &str) -> Self {
let root = base.join(project);
let socket = resolve_socket_path(&root, project);
Self {
runtime: root.clone(),
data: root.clone(),
logs: root,
socket,
}
}
#[must_use]
pub fn pid_file(&self) -> PathBuf {
self.runtime.join("serve.pid")
}
#[must_use]
pub fn socket_file(&self) -> PathBuf {
self.socket.clone()
}
#[must_use]
pub fn addr_file(&self) -> PathBuf {
self.runtime.join("serve.addr")
}
#[must_use]
pub fn mode_file(&self) -> PathBuf {
self.runtime.join("serve.mode")
}
#[must_use]
pub fn ready_file(&self) -> PathBuf {
self.runtime.join("serve.ready")
}
#[must_use]
pub fn stop_file(&self) -> PathBuf {
self.runtime.join("serve.stop")
}
#[must_use]
pub fn service_record_file(&self) -> PathBuf {
self.runtime.join("serve.service.toml")
}
#[must_use]
pub fn pg_data_dir(&self) -> PathBuf {
self.data.join("pg")
}
#[must_use]
pub fn log_file(&self) -> PathBuf {
self.logs.join("serve.log")
}
pub fn ensure_dirs(&self) -> std::io::Result<()> {
std::fs::create_dir_all(&self.runtime)?;
std::fs::create_dir_all(&self.logs)?;
std::fs::create_dir_all(&self.data)?;
#[cfg(unix)]
harden_private_dir(&self.runtime)?;
#[cfg(windows)]
{
restrict_to_owner(&self.runtime)?;
restrict_to_owner(&self.logs)?;
restrict_to_owner(&self.data)?;
}
let socket_parent = self.socket.parent().unwrap_or(self.runtime.as_path());
std::fs::create_dir_all(socket_parent)?;
#[cfg(unix)]
harden_private_dir(socket_parent)?;
Ok(())
}
}
#[cfg(unix)]
fn harden_private_dir(dir: &Path) -> std::io::Result<()> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let meta = std::fs::symlink_metadata(dir)?;
if !meta.file_type().is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"refusing to use {} for the daemon socket: not a directory",
dir.display()
),
));
}
if meta.uid() != nix::unistd::geteuid().as_raw() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"refusing to use {} for the daemon socket: not owned by the current user",
dir.display()
),
));
}
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
}
#[cfg_attr(
not(windows),
allow(
dead_code,
reason = "the Windows hardening arm, compiled and tested everywhere"
)
)]
fn owner_only_acl_args(dir: &Path, owner: &str) -> Vec<std::ffi::OsString> {
const LOCAL_SYSTEM_SID: &str = "*S-1-5-18";
const ADMINISTRATORS_SID: &str = "*S-1-5-32-544";
const FULL_INHERITED: &str = ":(OI)(CI)F";
let mut args = vec![dir.as_os_str().to_os_string()];
args.push("/inheritance:r".into());
for trustee in [owner, LOCAL_SYSTEM_SID, ADMINISTRATORS_SID] {
args.push("/grant:r".into());
args.push(format!("{trustee}{FULL_INHERITED}").into());
}
args.push("/Q".into());
args
}
#[cfg_attr(
not(windows),
allow(
dead_code,
reason = "the Windows hardening arm, compiled and tested everywhere"
)
)]
fn reset_dacl_args(dir: &Path) -> Vec<std::ffi::OsString> {
vec![
dir.as_os_str().to_os_string(),
"/reset".into(),
"/T".into(),
"/L".into(),
"/Q".into(),
]
}
#[cfg_attr(
not(windows),
allow(
dead_code,
reason = "the Windows hardening arm, compiled and tested everywhere"
)
)]
fn owner_sid_from_whoami(output: &str) -> Option<String> {
let sid = output
.lines()
.find(|line| line.contains("S-1-"))?
.rsplit(',')
.next()?
.trim()
.trim_matches('"')
.trim();
let is_sid = sid.starts_with("S-1-")
&& sid.len() > 4
&& sid[4..].chars().all(|c| c.is_ascii_digit() || c == '-');
is_sid.then(|| format!("*{sid}"))
}
#[cfg(windows)]
fn system32_tool(name: &str) -> PathBuf {
PathBuf::from(std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_owned()))
.join("System32")
.join(name)
}
#[cfg(windows)]
fn current_owner_sid() -> std::io::Result<String> {
let output = std::process::Command::new(system32_tool("whoami.exe"))
.args(["/user", "/fo", "csv", "/nh"])
.output()?;
owner_sid_from_whoami(&String::from_utf8_lossy(&output.stdout)).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"could not determine this account's SID, so daemon state cannot be \
restricted to it",
)
})
}
#[cfg(windows)]
fn restrict_to_owner(dir: &Path) -> std::io::Result<()> {
let meta = std::fs::symlink_metadata(dir)?;
if meta.file_type().is_symlink() || !meta.file_type().is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"refusing to use {} for daemon state: it is a link or not a \
directory, so its access cannot be restricted",
dir.display()
),
));
}
let owner = current_owner_sid()?;
let icacls = system32_tool("icacls.exe");
let take_ownership = vec![
dir.as_os_str().to_os_string(),
"/setowner".into(),
std::ffi::OsString::from(&owner),
"/T".into(),
"/L".into(),
"/Q".into(),
];
run_icacls(&icacls, dir, &take_ownership, &owner)?;
run_icacls(&icacls, dir, &reset_dacl_args(dir), &owner)?;
run_icacls(&icacls, dir, &owner_only_acl_args(dir, &owner), &owner)
}
#[cfg(windows)]
fn run_icacls(
icacls: &Path,
dir: &Path,
args: &[std::ffi::OsString],
owner: &str,
) -> std::io::Result<()> {
let output = std::process::Command::new(icacls).args(args).output()?;
if output.status.success() {
return Ok(());
}
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"refusing to use {} for daemon state: could not restrict it to {owner} \
({} exited {}): {}",
dir.display(),
icacls.display(),
output.status,
String::from_utf8_lossy(&output.stderr).trim(),
),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_from_base_layout() {
let paths = RuntimePaths::from_base(Path::new("/var/run"), "demo");
assert_eq!(paths.pid_file(), Path::new("/var/run/demo/serve.pid"));
assert_eq!(paths.socket_file(), Path::new("/var/run/demo/serve.sock"));
assert_eq!(paths.addr_file(), Path::new("/var/run/demo/serve.addr"));
assert_eq!(paths.pg_data_dir(), Path::new("/var/run/demo/pg"));
assert_eq!(paths.log_file(), Path::new("/var/run/demo/serve.log"));
}
#[test]
fn socket_uses_natural_path_when_short() {
let paths = RuntimePaths::from_base(Path::new("/var/run"), "demo");
assert_eq!(paths.socket_file(), Path::new("/var/run/demo/serve.sock"));
assert!(paths.socket_file().as_os_str().len() <= MAX_UNIX_SOCKET_PATH);
}
#[cfg(unix)]
#[test]
fn ensure_dirs_makes_socket_parent_private() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let paths = RuntimePaths::from_base(dir.path(), "p");
paths.ensure_dirs().expect("ensure_dirs");
let parent = paths.socket_file().parent().unwrap().to_path_buf();
let mode = std::fs::metadata(&parent).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700, "socket dir must be owner-only");
}
#[cfg(unix)]
#[test]
fn harden_private_dir_rejects_symlink() {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("real");
std::fs::create_dir(&target).unwrap();
let link = dir.path().join("link");
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(harden_private_dir(&link).is_err());
}
#[test]
fn socket_falls_back_to_short_path_when_runtime_too_long() {
let long_base = format!("/{}", "longsegment".repeat(12)); temp_env::with_var("XDG_RUNTIME_DIR", None::<&str>, || {
let paths = RuntimePaths::from_base(Path::new(&long_base), "proj");
let sock = paths.socket_file();
assert!(
sock.as_os_str().len() <= MAX_UNIX_SOCKET_PATH,
"socket path still too long ({} bytes): {}",
sock.as_os_str().len(),
sock.display()
);
assert_ne!(sock, Path::new(&long_base).join("proj").join("serve.sock"));
assert!(paths.pid_file().starts_with(&long_base));
assert!(paths.addr_file().starts_with(&long_base));
});
}
#[cfg(unix)]
#[test]
fn paths_socket_absolute_under_base() {
let paths = RuntimePaths::from_base(Path::new("/tmp/xdg-base"), "svc");
let sock = paths.socket_file();
assert!(sock.is_absolute());
assert!(sock.starts_with("/tmp/xdg-base"));
}
#[test]
fn stop_request_file_sits_beside_the_pidfile() {
let paths = RuntimePaths::from_base(Path::new("/var/run"), "demo");
assert_eq!(paths.stop_file(), Path::new("/var/run/demo/serve.stop"));
assert_eq!(
paths.stop_file().parent(),
paths.pid_file().parent(),
"stop request and pidfile must share a directory"
);
}
#[test]
fn owner_only_acl_grants_exactly_the_owner_system_and_administrators() {
let args = owner_only_acl_args(Path::new(r"C:\state\demo"), "*S-1-5-21-1-2-3-1001");
let rendered: Vec<String> = args
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(rendered[0], r"C:\state\demo");
assert!(
rendered.contains(&"/inheritance:r".to_owned()),
"inherited ACEs must be dropped, or a permissive parent leaks in: {rendered:?}"
);
let grants = rendered
.iter()
.skip_while(|a| *a != "/grant:r")
.filter(|a| a.contains(":(OI)(CI)F"))
.count();
assert_eq!(grants, 3, "{rendered:?}");
assert_eq!(
rendered.iter().filter(|a| *a == "/grant:r").count(),
3,
"every trustee needs its own /grant:r: {rendered:?}"
);
assert!(
rendered.contains(&"*S-1-5-21-1-2-3-1001:(OI)(CI)F".to_owned()),
"{rendered:?}"
);
}
#[test]
fn the_dacl_is_cleared_before_it_is_rebuilt() {
let args = reset_dacl_args(Path::new(r"C:\state\demo"));
let rendered: Vec<String> = args
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(rendered[0], r"C:\state\demo");
assert!(rendered.contains(&"/reset".to_owned()), "{rendered:?}");
assert!(rendered.contains(&"/T".to_owned()), "{rendered:?}");
assert!(rendered.contains(&"/L".to_owned()), "{rendered:?}");
assert!(
!rendered.iter().any(|a| a.starts_with("/grant")),
"{rendered:?}"
);
}
#[test]
fn owner_only_acl_names_the_builtin_trustees_by_sid_not_by_localized_name() {
let args = owner_only_acl_args(Path::new("state"), "*S-1-5-21-1-2-3-1001");
let rendered: Vec<String> = args
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
rendered.contains(&"*S-1-5-18:(OI)(CI)F".to_owned()),
"{rendered:?}"
);
assert!(
rendered.contains(&"*S-1-5-32-544:(OI)(CI)F".to_owned()),
"{rendered:?}"
);
assert!(
!rendered.iter().any(|a| a.starts_with("SYSTEM:")),
"a localized trustee name must not be used: {rendered:?}"
);
}
#[test]
fn owner_sid_is_taken_from_the_token_not_the_environment() {
assert_eq!(
owner_sid_from_whoami(
"\"CORP\\dev\",\"S-1-5-21-1111111111-222222222-333333333-1001\"\r\n"
)
.as_deref(),
Some("*S-1-5-21-1111111111-222222222-333333333-1001")
);
}
#[test]
fn owner_sid_refuses_anything_that_is_not_a_sid() {
assert_eq!(owner_sid_from_whoami(""), None);
assert_eq!(owner_sid_from_whoami("ERROR: Access is denied.\n"), None);
assert_eq!(owner_sid_from_whoami("\"CORP\\dev\",\"NOT-A-SID\"\n"), None);
assert_eq!(owner_sid_from_whoami("\"CORP\\dev\",\"S-1-5-x-1\"\n"), None);
}
#[cfg(windows)]
#[test]
fn ensure_dirs_restricts_the_runtime_dir_to_its_owner() {
let dir = tempfile::tempdir().expect("tempdir");
let paths = RuntimePaths::from_base(dir.path(), "p");
paths.ensure_dirs().expect("ensure_dirs");
let output = std::process::Command::new(system32_tool("icacls.exe"))
.arg(paths.pid_file().parent().expect("runtime dir"))
.output()
.expect("icacls");
let acl = String::from_utf8_lossy(&output.stdout);
assert!(
!acl.contains("S-1-1-0") && !acl.contains("Everyone"),
"runtime dir is reachable by Everyone: {acl}"
);
assert!(
!acl.contains("S-1-5-32-545") && !acl.contains("\\Users:"),
"runtime dir is reachable by all local users: {acl}"
);
}
#[cfg(windows)]
#[test]
fn resolve_puts_windows_state_under_local_app_data() {
temp_env::with_var(RUNTIME_DIR_ENV, None::<&str>, || {
let paths = RuntimePaths::resolve("demo").expect("resolve");
let local = std::env::var("LOCALAPPDATA").expect("LOCALAPPDATA");
assert!(
paths.pid_file().starts_with(&local),
"daemon state must live under %LOCALAPPDATA%: {}",
paths.pid_file().display()
);
assert!(
paths.pg_data_dir().starts_with(&local),
"the managed cluster must not roam: {}",
paths.pg_data_dir().display()
);
});
}
#[test]
fn runtime_parts_round_trip_without_re_resolving() {
let original = RuntimePaths::from_base(Path::new("/var/run"), "demo");
let rebuilt = RuntimePaths::from_parts(original.parts());
assert_eq!(rebuilt, original);
assert_eq!(rebuilt.pid_file(), original.pid_file());
assert_eq!(rebuilt.log_file(), original.log_file());
assert_eq!(rebuilt.pg_data_dir(), original.pg_data_dir());
}
#[test]
fn service_record_lives_inside_the_restricted_runtime_dir() {
let paths = RuntimePaths::from_base(Path::new("/var/run"), "demo");
assert_eq!(
paths.service_record_file().parent(),
paths.pid_file().parent()
);
}
#[test]
fn resolve_honors_env_override() {
let dir = tempfile::tempdir().expect("tempdir");
temp_env::with_var(RUNTIME_DIR_ENV, Some(dir.path()), || {
let paths = RuntimePaths::resolve("demo").expect("resolve with override");
assert_eq!(paths.pid_file(), dir.path().join("demo").join("serve.pid"));
assert!(paths.socket_file().starts_with(dir.path()));
});
}
}