use std::ffi::{OsStr, OsString};
use std::io::{self, Read, Write};
use std::path::{Component, Path, PathBuf};
#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
mod ancestors;
#[cfg(target_os = "macos")]
mod darwin_acl;
#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
pub(crate) use ancestors::{DataRootAncestorError, validate_ambient_backend_ancestors};
use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt};
use cap_std::ambient_authority;
use cap_std::fs::{Dir, DirBuilder, OpenOptions};
#[cfg(unix)]
use cap_std::fs::{DirBuilderExt, MetadataExt as _, OpenOptionsExt, PermissionsExt};
#[cfg(all(unix, not(target_os = "macos")))]
use std::os::fd::AsRawFd as _;
#[cfg(target_os = "macos")]
use std::os::unix::ffi::OsStringExt as _;
const PRIVATE_DIR_MODE: u32 = 0o700;
const PRIVATE_FILE_MODE: u32 = 0o600;
#[cfg(unix)]
const PRIVATE_DIR_MODE_BITS: rustix::fs::Mode = rustix::fs::Mode::RWXU;
#[cfg(unix)]
const _: () = assert!(PRIVATE_DIR_MODE == 0o700);
#[cfg(unix)]
const _: () = assert!(PRIVATE_DIR_MODE_BITS.bits() == 0o700);
pub(crate) struct ConfinedDir {
dir: Dir,
}
impl ConfinedDir {
pub(crate) fn open(path: &Path) -> io::Result<Self> {
let root = Self {
dir: open_absolute(path, false)?,
};
root.ensure_private_mode(path)?;
Ok(root)
}
pub(crate) fn open_or_create(path: &Path) -> io::Result<Self> {
let root = Self {
dir: open_absolute(path, true)?,
};
root.ensure_private_mode(path)?;
Ok(root)
}
pub(crate) fn read_to_string(&self, relative: &Path) -> io::Result<String> {
let mut file = self.open_file(relative, false)?;
let mut value = String::new();
file.read_to_string(&mut value)?;
Ok(value)
}
pub(crate) fn read(&self, relative: &Path) -> io::Result<Vec<u8>> {
let mut file = self.open_file(relative, false)?;
let mut value = Vec::new();
file.read_to_end(&mut value)?;
Ok(value)
}
pub(crate) fn create_new(&self, relative: &Path, bytes: &[u8]) -> io::Result<()> {
let (parent, name) = self.open_parent(relative, true)?;
let mut options = private_file_options();
options.write(true).create_new(true);
let mut file = parent.open_with(name, &options)?;
if let Err(error) = write_and_sync(&mut file, bytes) {
drop(file);
let _ = parent.remove_file(name);
return Err(error);
}
Ok(())
}
pub(crate) fn atomic_write(&self, relative: &Path, bytes: &[u8]) -> io::Result<()> {
let (parent, name) = self.open_parent(relative, true)?;
match parent.symlink_metadata(name) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"refusing to replace a symbolic link",
));
}
Ok(_) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
let temp_name = OsString::from(format!(".aion-{}.tmp", uuid::Uuid::new_v4()));
let mut options = private_file_options();
options.write(true).create_new(true);
let mut temp = parent.open_with(&temp_name, &options)?;
if let Err(error) = write_and_sync(&mut temp, bytes) {
drop(temp);
let _ = parent.remove_file(&temp_name);
return Err(error);
}
drop(temp);
if let Err(error) = parent.rename(&temp_name, &parent, name) {
let _ = parent.remove_file(&temp_name);
return Err(error);
}
Ok(())
}
pub(crate) fn remove_file(&self, relative: &Path) -> io::Result<()> {
let (parent, name) = self.open_parent(relative, false)?;
parent.remove_file(name)
}
pub(crate) fn list_awl(&self) -> io::Result<Vec<PathBuf>> {
let mut paths = Vec::new();
visit_awl(&self.dir, Path::new(""), &mut paths)?;
Ok(paths)
}
pub(crate) fn create_dir_all(&self, relative: &Path) -> io::Result<()> {
drop(self.open_dir(relative, true)?);
Ok(())
}
#[cfg(unix)]
pub(crate) fn backend_path(&self) -> io::Result<PathBuf> {
#[cfg(any(target_os = "linux", target_os = "android"))]
{
Ok(Path::new("/proc/self/fd").join(self.dir.as_raw_fd().to_string()))
}
#[cfg(target_os = "macos")]
{
let path = rustix::fs::getpath(&self.dir)?;
Ok(PathBuf::from(OsString::from_vec(path.into_bytes())))
}
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "macos")))]
{
std::fs::canonicalize(Path::new("/dev/fd").join(self.dir.as_raw_fd().to_string()))
}
}
pub(crate) fn metadata(&self) -> io::Result<cap_std::fs::Metadata> {
self.dir.dir_metadata()
}
pub(crate) fn harden_tree(&self) -> io::Result<()> {
#[cfg(unix)]
harden_dir(&self.dir)?;
Ok(())
}
fn ensure_private_mode(&self, path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
let metadata = self.metadata()?;
let mode = metadata.permissions().mode() & 0o777;
let grants_group_or_world = mode & 0o077 != 0;
if !grants_group_or_world {
return Ok(());
}
let owner = metadata.uid();
let effective = rustix::process::geteuid().as_raw();
if owner != effective {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"sensitive root `{}` has mode {mode:04o}, which grants group or world \
access, and is owned by uid {owner} rather than the uid {effective} this \
server runs as. Aion will not change another principal's directory. \
Either run the server as uid {owner}, or point this root at a directory \
owned by uid {effective}.",
path.display()
),
));
}
rustix::fs::fchmod(&self.dir, PRIVATE_DIR_MODE_BITS).map_err(|errno| {
io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"sensitive root `{}` has mode {mode:04o}, which grants group or world \
access, and Aion could not tighten it to 0700: {errno}. Move this root \
onto a filesystem that carries Unix permissions, or pre-create it with \
mode 0700.",
path.display()
),
)
})?;
let applied = self.metadata()?.permissions().mode() & 0o777;
if applied & 0o077 != 0 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"sensitive root `{}` still reports mode {applied:04o} after Aion set it \
to 0700, so this filesystem does not honour Unix permissions and Aion \
cannot keep workflow state private here. Move this root onto a \
filesystem that does.",
path.display()
),
));
}
let previous_mode = format!("{mode:04o}");
let applied_mode = format!("{PRIVATE_DIR_MODE:04o}");
tracing::info!(
sensitive_root = %path.display(),
%previous_mode,
%applied_mode,
"tightened a sensitive root to owner-only: it granted group or world access and \
the server's own user owns it"
);
}
#[cfg(not(unix))]
let _ = path;
Ok(())
}
fn open_file(&self, relative: &Path, create_parents: bool) -> io::Result<cap_std::fs::File> {
let (parent, name) = self.open_parent(relative, create_parents)?;
let mut options = OpenOptions::new();
options.read(true).follow(FollowSymlinks::No);
parent.open_with(name, &options)
}
fn open_parent<'a>(&self, relative: &'a Path, create: bool) -> io::Result<(Dir, &'a OsStr)> {
validate_relative(relative)?;
let name = relative.file_name().ok_or_else(invalid_relative)?;
let parent = relative.parent().unwrap_or_else(|| Path::new(""));
self.open_dir(parent, create).map(|dir| (dir, name))
}
fn open_dir(&self, relative: &Path, create: bool) -> io::Result<Dir> {
validate_relative_or_empty(relative)?;
let mut current = self.dir.try_clone()?;
for component in relative.components() {
let Component::Normal(name) = component else {
return Err(invalid_relative());
};
current = open_child_dir(¤t, name, create)?;
}
Ok(current)
}
}
fn open_absolute(path: &Path, create: bool) -> io::Result<Dir> {
let absolute = std::path::absolute(path)?;
let (anchor, names) = split_absolute(&absolute)?;
let mut current = Dir::open_ambient_dir(&anchor, ambient_authority())?;
for (index, name) in names.into_iter().enumerate() {
match open_child_dir(¤t, &name, create) {
Ok(child) => current = child,
Err(error) if index == 0 => {
let alias = anchor.join(&name);
let metadata = std::fs::symlink_metadata(&alias)?;
if !metadata.file_type().is_symlink() {
return Err(component_error(&name, &error));
}
let canonical = std::fs::canonicalize(alias)?;
current = Dir::open_ambient_dir(canonical, ambient_authority())?;
}
Err(error) => return Err(component_error(&name, &error)),
}
}
Ok(current)
}
fn split_absolute(path: &Path) -> io::Result<(PathBuf, Vec<OsString>)> {
let mut anchor = PathBuf::new();
let mut names = Vec::new();
for component in path.components() {
match component {
Component::Prefix(_) | Component::RootDir => anchor.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
if names.pop().is_none() {
return Err(invalid_relative());
}
}
Component::Normal(name) => names.push(name.to_owned()),
}
}
if anchor.as_os_str().is_empty() {
return Err(invalid_relative());
}
Ok((anchor, names))
}
fn component_error(name: &OsStr, error: &io::Error) -> io::Error {
io::Error::new(
error.kind(),
format!(
"failed to open real directory component `{}`: {error}",
name.to_string_lossy()
),
)
}
fn open_child_dir(parent: &Dir, name: &OsStr, create: bool) -> io::Result<Dir> {
match parent.open_dir_nofollow(name) {
Ok(dir) => Ok(dir),
Err(error) if create && error.kind() == io::ErrorKind::NotFound => {
let mut builder = DirBuilder::new();
#[cfg(unix)]
builder.mode(PRIVATE_DIR_MODE);
match parent.create_dir_with(name, &builder) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error),
}
parent.open_dir_nofollow(name)
}
Err(error) => Err(error),
}
}
fn private_file_options() -> OpenOptions {
let mut options = OpenOptions::new();
options.follow(FollowSymlinks::No);
#[cfg(unix)]
options.mode(PRIVATE_FILE_MODE);
options
}
fn write_and_sync(file: &mut cap_std::fs::File, bytes: &[u8]) -> io::Result<()> {
file.write_all(bytes)?;
file.sync_all()
}
fn visit_awl(dir: &Dir, relative: &Path, paths: &mut Vec<PathBuf>) -> io::Result<()> {
for entry in dir.entries()? {
let entry = entry?;
let name = entry.file_name();
let file_type = entry.file_type()?;
if file_type.is_symlink() {
continue;
}
let child_relative = relative.join(&name);
if file_type.is_dir() {
let child = dir.open_dir_nofollow(&name)?;
visit_awl(&child, &child_relative, paths)?;
} else if file_type.is_file() && child_relative.extension() == Some(OsStr::new("awl")) {
paths.push(child_relative);
}
}
Ok(())
}
#[cfg(unix)]
fn harden_dir(dir: &Dir) -> io::Result<()> {
dir.set_permissions(
Path::new("."),
cap_std::fs::Permissions::from_mode(PRIVATE_DIR_MODE),
)?;
for entry in dir.entries()? {
let entry = entry?;
let name = entry.file_name();
let file_type = entry.file_type()?;
if file_type.is_symlink() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"sensitive state contains symbolic link `{}`",
name.to_string_lossy()
),
));
}
if file_type.is_dir() {
let child = dir.open_dir_nofollow(&name)?;
harden_dir(&child)?;
} else if file_type.is_file() {
let mut options = OpenOptions::new();
options.read(true).follow(FollowSymlinks::No);
let file = dir.open_with(&name, &options)?;
file.set_permissions(cap_std::fs::Permissions::from_mode(PRIVATE_FILE_MODE))?;
}
}
Ok(())
}
fn validate_relative(path: &Path) -> io::Result<()> {
if path.as_os_str().is_empty() {
return Err(invalid_relative());
}
validate_relative_or_empty(path)
}
fn validate_relative_or_empty(path: &Path) -> io::Result<()> {
if path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(invalid_relative());
}
Ok(())
}
fn invalid_relative() -> io::Error {
io::Error::new(
io::ErrorKind::InvalidInput,
"path must be relative and contain only normal components",
)
}
#[cfg(all(test, target_os = "macos"))]
pub(crate) fn darwin_user_uuid_for_test(uid: u32) -> io::Result<uuid::Uuid> {
darwin_acl::user_uuid_for_test(uid)
}
#[cfg(not(unix))]
pub(crate) fn validate_real_directory_root(path: &Path, label: &str) -> io::Result<()> {
let metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{label} `{}` is not a real directory", path.display()),
));
}
Ok(())
}
#[cfg(all(test, unix))]
mod tests {
use std::os::unix::fs::PermissionsExt as _;
use super::*;
#[test]
fn nested_sensitive_roots_and_files_ignore_a_permissive_umask()
-> Result<(), Box<dyn std::error::Error>> {
const PROBE: &str = "AION_PRIVATE_MODE_UMASK_PROBE";
if let Some(path) = std::env::var_os(PROBE) {
return assert_private_creation(Path::new(&path));
}
let sandbox = crate::test_support::private_tempdir()?;
let executable = std::env::current_exe()?;
let status = std::process::Command::new("sh")
.arg("-c")
.arg(
"umask 000; exec \"$1\" --exact \
filesystem::tests::nested_sensitive_roots_and_files_ignore_a_permissive_umask \
--nocapture",
)
.arg("aion-private-mode-probe")
.arg(executable)
.env(PROBE, sandbox.path())
.status()?;
assert!(status.success(), "private-mode umask probe failed");
Ok(())
}
fn assert_private_creation(sandbox: &Path) -> Result<(), Box<dyn std::error::Error>> {
let home = sandbox.join("aion-home");
let authoring = home.join("authoring");
let root = ConfinedDir::open_or_create(&authoring)?;
root.create_new(Path::new("private.txt"), b"secret")?;
assert_eq!(
std::fs::metadata(&home)?.permissions().mode() & 0o777,
0o700
);
assert_eq!(
std::fs::metadata(&authoring)?.permissions().mode() & 0o777,
0o700
);
assert_eq!(
std::fs::metadata(authoring.join("private.txt"))?
.permissions()
.mode()
& 0o777,
0o600
);
Ok(())
}
#[test]
fn a_missing_root_is_created_owner_only() -> Result<(), Box<dyn std::error::Error>> {
let sandbox = crate::test_support::private_tempdir()?;
let root = sandbox.path().join("nested").join("aion-home");
let (captured, opened) = crate::test_support::CapturedLogs::capture(|| {
ConfinedDir::open_or_create(&root).map(drop)
});
opened?;
assert_eq!(
std::fs::metadata(&root)?.permissions().mode() & 0o777,
0o700
);
assert!(
!captured.text()?.contains("tightened a sensitive root"),
"a freshly created root must not need tightening"
);
Ok(())
}
#[test]
fn a_permissive_root_we_own_is_tightened_and_logged() -> Result<(), Box<dyn std::error::Error>>
{
let sandbox = crate::test_support::private_tempdir()?;
let root = sandbox.path().join("aion-home");
std::fs::create_dir(&root)?;
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755))?;
let (captured, opened) =
crate::test_support::CapturedLogs::capture(|| ConfinedDir::open(&root).map(drop));
opened?;
assert_eq!(
std::fs::metadata(&root)?.permissions().mode() & 0o777,
0o700
);
let logs = captured.text()?;
assert!(logs.contains("tightened a sensitive root to owner-only"));
assert!(logs.contains(&root.display().to_string()));
assert!(logs.contains("0755"), "the previous mode was not logged");
assert!(logs.contains("0700"), "the applied mode was not logged");
Ok(())
}
#[test]
fn a_world_writable_root_we_own_is_tightened() -> Result<(), Box<dyn std::error::Error>> {
let sandbox = crate::test_support::private_tempdir()?;
let root = sandbox.path().join("aion-data");
std::fs::create_dir(&root)?;
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o777))?;
ConfinedDir::open_or_create(&root)?;
assert_eq!(
std::fs::metadata(&root)?.permissions().mode() & 0o777,
0o700
);
Ok(())
}
#[test]
fn an_already_private_root_is_untouched_and_silent() -> Result<(), Box<dyn std::error::Error>> {
let sandbox = crate::test_support::private_tempdir()?;
let root = sandbox.path().join("aion-home");
std::fs::create_dir(&root)?;
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700))?;
let (captured, opened) =
crate::test_support::CapturedLogs::capture(|| ConfinedDir::open(&root).map(drop));
opened?;
assert_eq!(
std::fs::metadata(&root)?.permissions().mode() & 0o777,
0o700
);
assert!(captured.text()?.is_empty());
Ok(())
}
#[test]
fn a_permissive_root_owned_by_another_user_refuses_with_remediation()
-> Result<(), Box<dyn std::error::Error>> {
let effective = rustix::process::geteuid().as_raw();
if effective == 0 {
tracing::info!(
"skipping the foreign-owner refusal pin: running as root, which owns every \
candidate directory"
);
return Ok(());
}
let foreign = Path::new("/usr");
let metadata = std::fs::symlink_metadata(foreign)?;
let owner = std::os::unix::fs::MetadataExt::uid(&metadata);
let mode = metadata.permissions().mode() & 0o777;
let grants_group_or_world = mode & 0o077 != 0;
if owner == effective || !grants_group_or_world {
tracing::info!(
path = %foreign.display(),
"skipping the foreign-owner refusal pin: this system's /usr is not a \
foreign-owned, group/world-readable directory"
);
return Ok(());
}
let error = ConfinedDir::open(foreign)
.err()
.ok_or("a permissive foreign-owned root was accepted")?;
let message = error.to_string();
assert!(message.contains("/usr"), "the path was not named");
assert!(
message.contains(&format!("mode {mode:04o}")),
"the offending mode was not named"
);
assert!(message.contains(&format!("uid {owner}")));
assert!(message.contains(&format!("uid {effective}")));
assert!(message.contains("run the server as"), "no remediation");
assert_eq!(
std::fs::symlink_metadata(foreign)?.permissions().mode() & 0o777,
mode,
"a foreign-owned directory must never be modified"
);
Ok(())
}
#[test]
fn a_symlinked_root_refuses_and_leaves_its_target_alone()
-> Result<(), Box<dyn std::error::Error>> {
let sandbox = crate::test_support::private_tempdir()?;
let target = sandbox.path().join("elsewhere");
let link = sandbox.path().join("aion-home");
std::fs::create_dir(&target)?;
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))?;
std::os::unix::fs::symlink(&target, &link)?;
let error = ConfinedDir::open(&link)
.err()
.ok_or("a symlinked root was accepted")?;
assert!(
error.to_string().contains("aion-home"),
"the refusal did not name the offending component"
);
assert!(ConfinedDir::open_or_create(&link).is_err());
assert_eq!(
std::fs::metadata(&target)?.permissions().mode() & 0o777,
0o755,
"a symlink target must never be tightened"
);
Ok(())
}
#[test]
fn a_root_occupied_by_a_file_refuses() -> Result<(), Box<dyn std::error::Error>> {
let sandbox = crate::test_support::private_tempdir()?;
let occupied = sandbox.path().join("aion-home");
std::fs::write(&occupied, b"not a directory")?;
let error = ConfinedDir::open_or_create(&occupied)
.err()
.ok_or("a file standing in for a root was accepted")?;
assert!(error.to_string().contains("aion-home"));
Ok(())
}
}