lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 74b0181f4585932d20e8b60097eed1dfc18d77b5
parent be9db78e060ebc0000fa7827ac32efa3f6504f53
Author: triesap <tyson@radroots.org>
Date:   Thu, 27 Aug 2026 20:11:24 +0000

runtime-paths: provision canonical state directories

Diffstat:
MCargo.lock | 2++
Mcontracts/api_baselines/radroots_runtime_paths.txt | 20++++++++++++++++++++
Mcrates/runtime_paths/Cargo.toml | 2++
Mcrates/runtime_paths/README | 38+++++++++++++++++++++++++++++++-------
Mcrates/runtime_paths/src/context.rs | 14+++++++++++++-
Mcrates/runtime_paths/src/lib.rs | 2++
Acrates/runtime_paths/src/provision.rs | 1000+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/runtime_paths/src/service.rs | 6++++++
Mcrates/runtime_paths/tests/package_boundary.rs | 20++++++++++++++++++--
9 files changed, 1094 insertions(+), 10 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3420,8 +3420,10 @@ dependencies = [ name = "radroots_runtime_paths" version = "0.1.0-alpha" dependencies = [ + "rustix 1.1.4", "serde", "serde_json", + "tempfile", "thiserror 1.0.69", ] diff --git a/contracts/api_baselines/radroots_runtime_paths.txt b/contracts/api_baselines/radroots_runtime_paths.txt @@ -61,6 +61,19 @@ pub radroots_runtime_paths::ServiceIdentityKind::Instance pub radroots_runtime_paths::ServiceIdentityKind::Service impl core::fmt::Display for radroots_runtime_paths::ServiceIdentityKind pub fn radroots_runtime_paths::ServiceIdentityKind::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub enum radroots_runtime_paths::StateDirectoryProvisionError +pub radroots_runtime_paths::StateDirectoryProvisionError::Cleanup +pub radroots_runtime_paths::StateDirectoryProvisionError::DirectoryConflict +pub radroots_runtime_paths::StateDirectoryProvisionError::Filesystem +pub radroots_runtime_paths::StateDirectoryProvisionError::InvalidPlan +pub radroots_runtime_paths::StateDirectoryProvisionError::MissingDirectory +pub radroots_runtime_paths::StateDirectoryProvisionError::StateRootUnavailable +pub radroots_runtime_paths::StateDirectoryProvisionError::UnsafeDirectory +pub radroots_runtime_paths::StateDirectoryProvisionError::UnsupportedPlatform +pub radroots_runtime_paths::StateDirectoryProvisionError::UnsupportedProfile +impl core::error::Error for radroots_runtime_paths::StateDirectoryProvisionError +impl core::fmt::Display for radroots_runtime_paths::StateDirectoryProvisionError +pub fn radroots_runtime_paths::StateDirectoryProvisionError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct radroots_runtime_paths::InstanceId(_) impl radroots_runtime_paths::InstanceId pub fn radroots_runtime_paths::InstanceId::as_str(&self) -> &str @@ -120,6 +133,7 @@ pub fn radroots_runtime_paths::RuntimeContext::repo_local_root(&self) -> core::o pub fn radroots_runtime_paths::RuntimeContext::resolve(&radroots_runtime_paths::RadrootsPathResolver, radroots_runtime_paths::RuntimeContextBootstrap, radroots_runtime_paths::ServiceId, radroots_runtime_paths::InstanceId) -> core::result::Result<Self, radroots_runtime_paths::RuntimeContextError> pub fn radroots_runtime_paths::RuntimeContext::service(&self) -> &radroots_runtime_paths::ServiceId pub fn radroots_runtime_paths::RuntimeContext::sources(&self) -> &radroots_runtime_paths::RuntimeContextSources +pub fn radroots_runtime_paths::RuntimeContext::state_directory_plan(&self) -> core::result::Result<radroots_runtime_paths::RuntimeStateDirectoryPlan, radroots_runtime_paths::StateDirectoryProvisionError> impl core::fmt::Debug for radroots_runtime_paths::RuntimeContext pub fn radroots_runtime_paths::RuntimeContext::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl serde_core::ser::Serialize for radroots_runtime_paths::RuntimeContext @@ -137,6 +151,12 @@ pub fn radroots_runtime_paths::RuntimeContextSources::paths(&self) -> radroots_r pub fn radroots_runtime_paths::RuntimeContextSources::profile(&self) -> radroots_runtime_paths::RuntimeContextSource pub fn radroots_runtime_paths::RuntimeContextSources::repo_local_root(&self) -> core::option::Option<radroots_runtime_paths::RuntimeContextSource> pub fn radroots_runtime_paths::RuntimeContextSources::service(&self) -> radroots_runtime_paths::RuntimeContextSource +pub struct radroots_runtime_paths::RuntimeStateDirectoryPlan +impl radroots_runtime_paths::RuntimeStateDirectoryPlan +pub fn radroots_runtime_paths::RuntimeStateDirectoryPlan::profile(&self) -> radroots_runtime_paths::RadrootsPathProfile +pub fn radroots_runtime_paths::RuntimeStateDirectoryPlan::provision(&self) -> core::result::Result<(), radroots_runtime_paths::StateDirectoryProvisionError> +impl core::fmt::Debug for radroots_runtime_paths::RuntimeStateDirectoryPlan +pub fn radroots_runtime_paths::RuntimeStateDirectoryPlan::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct radroots_runtime_paths::ServiceCredentialArtifactName(_) impl radroots_runtime_paths::ServiceCredentialArtifactName pub fn radroots_runtime_paths::ServiceCredentialArtifactName::as_str(&self) -> &str diff --git a/crates/runtime_paths/Cargo.toml b/crates/runtime_paths/Cargo.toml @@ -13,8 +13,10 @@ documentation = "https://docs.rs/radroots_runtime_paths" readme = "README" [dependencies] +rustix = { workspace = true } serde = { workspace = true, features = ["derive", "std"] } thiserror = { workspace = true } [dev-dependencies] serde_json = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/runtime_paths/README b/crates/runtime_paths/README @@ -1,9 +1,9 @@ # radroots_runtime_paths `radroots_runtime_paths` provides canonical, typed runtime paths for Radroots -core libraries. Its public API is exposed only from the crate root. It performs -no filesystem I/O, creates no directories, and never reads the ambient process -environment. +core libraries. Its public API is exposed only from the crate root. Pure path +resolution never reads the ambient process environment or performs filesystem +I/O. State-directory provisioning is a separate explicit operation. ## Overview @@ -16,6 +16,8 @@ environment. bootstrap provenance. - A sealed `RuntimeContext` is the only public service-instance path construction boundary. +- A sealed state-directory plan validates or provisions only the canonical + `services/<service>/<instance>` suffix under an existing state root. - Service paths and common artifacts are non-forgeable immutable views. - Pure shared geonames and runtime-store helpers remain independent of service-instance path selection. @@ -90,6 +92,26 @@ resolution requires both injected `APPDATA` and `LOCALAPPDATA`. Repo-local resolution requires one explicit absolute, non-root base with no parent traversal. Repo-local never becomes an implicit or production fallback. +## State Directory Provisioning + +`RuntimeContext::state_directory_plan` is pure and performs no filesystem I/O. +Its sealed result exposes no caller-selected path or mutable component. Calling +`RuntimeStateDirectoryPlan::provision` is an explicit filesystem operation. + +For `InteractiveUser` and `RepoLocal`, the canonical data/state root must +already exist, be owned by the effective user, be owner-readable and +owner-searchable, and not be group- or other-writable. The provisioner may then +create only `services/<service>/<instance>`, one descriptor-relative component +at a time, with mode `0700`. It uses no-follow opens, validates every existing +directory, never changes an existing mode, and identity-checks any cleanup of +directories it created during a failed attempt. An entry whose identity cannot +be proven is preserved and the operation fails closed. + +For `ServiceHost`, the entire state-directory suffix must already exist. The +plan performs validation only and never creates or permission-repairs service- +host directories. Provisioning is implemented only on Linux and macOS; other +targets fail with a stable path-free unsupported-platform classification. + ## Common Artifacts | Artifact | Canonical location within the instance | @@ -100,10 +122,12 @@ traversal. Repo-local never becomes an implicit or production fallback. | Local admin socket | `<run>/admin.sock` | | Credential artifact | `<secrets>/<validated-credential-name>` | -The crate derives these paths but does not provision, open, inspect, remove, or -set permissions on them. Callers own those operations and their security -policy. The shared geonames and runtime-store helpers are pure joins over -caller-supplied roots and do not select or alter a `RuntimeContext`. +The crate derives these paths. Its explicit state-directory provisioner owns +only the narrow behavior described above; it does not create any common +artifact, configuration, cache, log, run, or secrets path. Callers own artifact +creation and its security policy. The shared geonames and runtime-store helpers +are pure joins over caller-supplied roots and do not select or alter a +`RuntimeContext`. ## Support Caveats diff --git a/crates/runtime_paths/src/context.rs b/crates/runtime_paths/src/context.rs @@ -7,7 +7,8 @@ use serde::{Serialize, Serializer, ser::SerializeStruct}; use thiserror::Error; use crate::{ - InstanceId, RadrootsPathProfile, RadrootsPathResolver, RadrootsServiceInstancePaths, ServiceId, + InstanceId, RadrootsPathProfile, RadrootsPathResolver, RadrootsServiceInstancePaths, + RuntimeStateDirectoryPlan, ServiceId, StateDirectoryProvisionError, }; /// Closed provenance vocabulary for effective runtime configuration. @@ -219,6 +220,17 @@ impl RuntimeContext { pub fn sources(&self) -> &RuntimeContextSources { &self.sources } + + /// Returns a sealed, filesystem-I/O-free plan for this instance's state directory. + /// + /// Calling this method does not inspect or mutate the filesystem. Callers must + /// explicitly invoke [`RuntimeStateDirectoryPlan::provision`] to validate or + /// create the governed state-directory suffix. + pub fn state_directory_plan( + &self, + ) -> Result<RuntimeStateDirectoryPlan, StateDirectoryProvisionError> { + RuntimeStateDirectoryPlan::from_context(self) + } } impl fmt::Debug for RuntimeContext { diff --git a/crates/runtime_paths/src/lib.rs b/crates/runtime_paths/src/lib.rs @@ -6,6 +6,7 @@ mod conventions; mod error; mod identifier; mod platform; +mod provision; mod roots; mod service; @@ -37,5 +38,6 @@ pub use identifier::{ ServiceIdentityKind, }; pub use platform::{RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPlatform}; +pub use provision::{RuntimeStateDirectoryPlan, StateDirectoryProvisionError}; pub use roots::RadrootsPathResolver; pub use service::RadrootsServiceInstancePaths; diff --git a/crates/runtime_paths/src/provision.rs b/crates/runtime_paths/src/provision.rs @@ -0,0 +1,1000 @@ +//! Explicit provisioning for one canonical service-instance state directory. + +use core::fmt; +use std::{ + error::Error, + ffi::{OsStr, OsString}, + path::{Component, Path, PathBuf}, +}; + +use crate::{InstanceId, RadrootsPathProfile, RuntimeContext, ServiceId}; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::fs::File; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +use rustix::{ + fs::{AtFlags, FileType, Mode, OFlags, fstat, mkdirat, open, openat, statat, unlinkat}, + process::geteuid, +}; + +const SERVICES_COMPONENT: &str = "services"; + +/// A sealed plan for validating or creating one canonical state directory. +/// +/// Construction is available only from [`RuntimeContext::state_directory_plan`]. +/// The plan owns no ambient-environment lookup and performs no filesystem I/O +/// until [`Self::provision`] is called. +/// +/// ```compile_fail +/// use radroots_runtime_paths::{RadrootsPathProfile, RuntimeStateDirectoryPlan}; +/// +/// let _ = RuntimeStateDirectoryPlan { +/// profile: RadrootsPathProfile::RepoLocal, +/// state_root: "/tmp/alternate".into(), +/// service: todo!(), +/// instance: todo!(), +/// }; +/// ``` +#[derive(Clone, PartialEq, Eq)] +pub struct RuntimeStateDirectoryPlan { + profile: RadrootsPathProfile, + state_root: PathBuf, + service: ServiceId, + instance: InstanceId, +} + +impl RuntimeStateDirectoryPlan { + pub(crate) fn from_context( + context: &RuntimeContext, + ) -> Result<Self, StateDirectoryProvisionError> { + let state_root = context.paths().state_root(); + validate_absolute_root(state_root)?; + let expected = state_root + .join(SERVICES_COMPONENT) + .join(context.service().as_str()) + .join(context.instance().as_str()); + if expected != context.paths().state() { + return Err(StateDirectoryProvisionError::InvalidPlan); + } + Ok(Self { + profile: context.profile(), + state_root: state_root.to_path_buf(), + service: context.service().clone(), + instance: context.instance().clone(), + }) + } + + /// Returns the path profile whose creation policy is frozen by this plan. + #[must_use] + pub fn profile(&self) -> RadrootsPathProfile { + self.profile + } + + /// Validates or creates the exact `services/<service>/<instance>` suffix. + /// + /// `InteractiveUser` and `RepoLocal` plans may create missing suffix + /// directories. `ServiceHost` plans validate an already-provisioned suffix + /// and never create it. Existing directories are never permission-repaired. + /// Every traversal and creation is descriptor-relative and rejects symlinks. + pub fn provision(&self) -> Result<(), StateDirectoryProvisionError> { + provision_supported(self) + } + + fn components(&self) -> [&OsStr; 3] { + [ + OsStr::new(SERVICES_COMPONENT), + OsStr::new(self.service.as_str()), + OsStr::new(self.instance.as_str()), + ] + } + + fn permits_creation(&self) -> Result<bool, StateDirectoryProvisionError> { + match self.profile { + RadrootsPathProfile::InteractiveUser | RadrootsPathProfile::RepoLocal => Ok(true), + RadrootsPathProfile::ServiceHost => Ok(false), + RadrootsPathProfile::MobileNative => { + Err(StateDirectoryProvisionError::UnsupportedProfile) + } + } + } +} + +impl fmt::Debug for RuntimeStateDirectoryPlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RuntimeStateDirectoryPlan") + .field("profile", &self.profile) + .field("state_root", &"[redacted]") + .field("service", &"[redacted]") + .field("instance", &"[redacted]") + .finish() + } +} + +/// Stable path-free failures from state-directory planning or provisioning. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StateDirectoryProvisionError { + InvalidPlan, + UnsupportedPlatform, + UnsupportedProfile, + StateRootUnavailable, + MissingDirectory, + DirectoryConflict, + UnsafeDirectory, + Filesystem, + Cleanup, +} + +impl fmt::Display for StateDirectoryProvisionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidPlan => "runtime state-directory plan is invalid", + Self::UnsupportedPlatform => { + "runtime state-directory provisioning is unsupported on this platform" + } + Self::UnsupportedProfile => { + "runtime state-directory provisioning is unsupported for this profile" + } + Self::StateRootUnavailable => "runtime state-directory root is unavailable or unsafe", + Self::MissingDirectory => "runtime state directory must already exist for this profile", + Self::DirectoryConflict => { + "runtime state-directory entry conflicts with the canonical plan" + } + Self::UnsafeDirectory => "runtime state directory failed security validation", + Self::Filesystem => "runtime state-directory filesystem operation failed", + Self::Cleanup => "runtime state-directory cleanup could not be proven complete", + }) + } +} + +impl Error for StateDirectoryProvisionError {} + +fn validate_absolute_root(root: &Path) -> Result<(), StateDirectoryProvisionError> { + if !root.is_absolute() || root.parent().is_none() { + return Err(StateDirectoryProvisionError::InvalidPlan); + } + let mut saw_root = false; + let mut saw_normal = false; + for component in root.components() { + match component { + Component::RootDir if !saw_root && !saw_normal => saw_root = true, + Component::Normal(_) if saw_root => saw_normal = true, + Component::Prefix(_) + | Component::CurDir + | Component::ParentDir + | Component::RootDir => { + return Err(StateDirectoryProvisionError::InvalidPlan); + } + Component::Normal(_) => return Err(StateDirectoryProvisionError::InvalidPlan), + } + } + if saw_normal { + Ok(()) + } else { + Err(StateDirectoryProvisionError::InvalidPlan) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn provision_supported( + _plan: &RuntimeStateDirectoryPlan, +) -> Result<(), StateDirectoryProvisionError> { + Err(StateDirectoryProvisionError::UnsupportedPlatform) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn provision_supported( + plan: &RuntimeStateDirectoryPlan, +) -> Result<(), StateDirectoryProvisionError> { + provision_with_operations(plan, &SystemProvisionOperations) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +trait ProvisionOperations { + fn after_create(&self, _component_index: usize) -> Result<(), StateDirectoryProvisionError> { + Ok(()) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +struct SystemProvisionOperations; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl ProvisionOperations for SystemProvisionOperations {} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DirectoryIdentity { + device: u64, + inode: u64, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +struct CreatedDirectory { + parent: File, + name: OsString, + held: File, + identity: DirectoryIdentity, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +struct CreationJournal { + state_root_path: PathBuf, + state_root: File, + state_root_identity: DirectoryIdentity, + entries: Vec<CreatedDirectory>, + committed: bool, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl CreationJournal { + fn new( + state_root_path: PathBuf, + state_root: File, + state_root_identity: DirectoryIdentity, + ) -> Self { + Self { + state_root_path, + state_root, + state_root_identity, + entries: Vec::new(), + committed: false, + } + } + + fn fail( + &mut self, + failure: StateDirectoryProvisionError, + ) -> Result<(), StateDirectoryProvisionError> { + match self.cleanup() { + Ok(()) => Err(failure), + Err(()) => Err(StateDirectoryProvisionError::Cleanup), + } + } + + fn cleanup(&mut self) -> Result<(), ()> { + if validate_absolute_directory_binding( + &self.state_root_path, + &self.state_root, + self.state_root_identity, + ) + .is_err() + { + return Err(()); + } + let mut clean = true; + for entry in self.entries.iter().rev() { + if cleanup_created_directory(entry).is_err() { + clean = false; + break; + } + } + if clean { + self.entries.clear(); + self.committed = true; + Ok(()) + } else { + Err(()) + } + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Drop for CreationJournal { + fn drop(&mut self) { + if !self.committed { + let _ = self.cleanup(); + } + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn provision_with_operations( + plan: &RuntimeStateDirectoryPlan, + operations: &dyn ProvisionOperations, +) -> Result<(), StateDirectoryProvisionError> { + let permits_creation = plan.permits_creation()?; + let state_root = open_absolute_directory(&plan.state_root)?; + let state_root_identity = validate_secure_directory(&state_root, false) + .map_err(|_| StateDirectoryProvisionError::StateRootUnavailable)?; + let mut current = state_root + .try_clone() + .map_err(|_| StateDirectoryProvisionError::Filesystem)?; + let mut journal = + CreationJournal::new(plan.state_root.clone(), state_root, state_root_identity); + let mut bindings = Vec::with_capacity(3); + + for (component_index, component) in plan.components().into_iter().enumerate() { + if validate_absolute_directory_binding( + &journal.state_root_path, + &journal.state_root, + journal.state_root_identity, + ) + .is_err() + { + return journal.fail(StateDirectoryProvisionError::StateRootUnavailable); + } + let parent = match current.try_clone() { + Ok(parent) => parent, + Err(_) => return journal.fail(StateDirectoryProvisionError::Filesystem), + }; + match open_directory_at(&current, component) { + Ok(next) => { + let identity = match validate_secure_directory(&next, false) { + Ok(identity) => identity, + Err(failure) => return journal.fail(failure), + }; + let held = match next.try_clone() { + Ok(held) => held, + Err(_) => return journal.fail(StateDirectoryProvisionError::Filesystem), + }; + bindings.push(DirectoryBinding { + parent, + name: component.to_os_string(), + held, + identity, + exact_owner_mode: false, + }); + current = next; + } + Err(rustix::io::Errno::NOENT) if !permits_creation => { + return journal.fail(StateDirectoryProvisionError::MissingDirectory); + } + Err(rustix::io::Errno::NOENT) => { + match mkdirat(&parent, component, Mode::RUSR | Mode::WUSR | Mode::XUSR) { + Ok(()) => {} + Err(rustix::io::Errno::EXIST) => match open_directory_at(&parent, component) { + Ok(next) => { + let identity = match validate_secure_directory(&next, false) { + Ok(identity) => identity, + Err(failure) => return journal.fail(failure), + }; + let held = match next.try_clone() { + Ok(held) => held, + Err(_) => { + return journal.fail(StateDirectoryProvisionError::Filesystem); + } + }; + bindings.push(DirectoryBinding { + parent, + name: component.to_os_string(), + held, + identity, + exact_owner_mode: false, + }); + current = next; + continue; + } + Err(_) => { + return journal.fail(StateDirectoryProvisionError::DirectoryConflict); + } + }, + Err(_) => return journal.fail(StateDirectoryProvisionError::Filesystem), + } + + let created_identity = match created_directory_identity(&parent, component) { + Ok(identity) => identity, + Err(failure) => return journal.fail(failure), + }; + let held = match open_directory_at(&parent, component) { + Ok(held) => held, + Err(_) => return journal.fail(StateDirectoryProvisionError::DirectoryConflict), + }; + let opened_identity = match validate_secure_directory(&held, true) { + Ok(identity) => identity, + Err(failure) => return journal.fail(failure), + }; + if opened_identity != created_identity { + return journal.fail(StateDirectoryProvisionError::DirectoryConflict); + } + journal.entries.push(CreatedDirectory { + parent, + name: component.to_os_string(), + held, + identity: created_identity, + }); + let Some(created) = journal.entries.last() else { + return journal.fail(StateDirectoryProvisionError::Filesystem); + }; + let next = match created.held.try_clone() { + Ok(next) => next, + Err(_) => return journal.fail(StateDirectoryProvisionError::Filesystem), + }; + let binding_parent = match created.parent.try_clone() { + Ok(binding_parent) => binding_parent, + Err(_) => return journal.fail(StateDirectoryProvisionError::Filesystem), + }; + let binding_held = match created.held.try_clone() { + Ok(binding_held) => binding_held, + Err(_) => return journal.fail(StateDirectoryProvisionError::Filesystem), + }; + if created.parent.sync_all().is_err() { + return journal.fail(StateDirectoryProvisionError::Filesystem); + } + if let Err(failure) = operations.after_create(component_index) { + return journal.fail(failure); + } + bindings.push(DirectoryBinding { + parent: binding_parent, + name: component.to_os_string(), + held: binding_held, + identity: created_identity, + exact_owner_mode: true, + }); + current = next; + } + Err(_) => return journal.fail(StateDirectoryProvisionError::DirectoryConflict), + } + } + + if validate_absolute_directory_binding( + &journal.state_root_path, + &journal.state_root, + journal.state_root_identity, + ) + .is_err() + { + return journal.fail(StateDirectoryProvisionError::StateRootUnavailable); + } + for binding in &bindings { + if validate_directory_binding(binding).is_err() { + return journal.fail(StateDirectoryProvisionError::DirectoryConflict); + } + } + + journal.committed = true; + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +struct DirectoryBinding { + parent: File, + name: OsString, + held: File, + identity: DirectoryIdentity, + exact_owner_mode: bool, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn validate_directory_binding(binding: &DirectoryBinding) -> Result<(), ()> { + let current = open_directory_at(&binding.parent, &binding.name).map_err(|_| ())?; + let held_identity = + validate_secure_directory(&binding.held, binding.exact_owner_mode).map_err(|_| ())?; + let current_identity = + validate_secure_directory(&current, binding.exact_owner_mode).map_err(|_| ())?; + if held_identity == binding.identity && current_identity == binding.identity { + Ok(()) + } else { + Err(()) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn validate_absolute_directory_binding( + path: &Path, + held: &File, + expected: DirectoryIdentity, +) -> Result<(), ()> { + let current = open_absolute_directory(path).map_err(|_| ())?; + let held_identity = validate_secure_directory(held, false).map_err(|_| ())?; + let current_identity = validate_secure_directory(&current, false).map_err(|_| ())?; + if held_identity == expected && current_identity == expected { + Ok(()) + } else { + Err(()) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn open_absolute_directory(root: &Path) -> Result<File, StateDirectoryProvisionError> { + let mut current = File::from( + open( + Path::new("/"), + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|_| StateDirectoryProvisionError::StateRootUnavailable)?, + ); + for component in root.components() { + match component { + Component::RootDir => {} + Component::Normal(name) => { + current = open_directory_at(&current, name) + .map_err(|_| StateDirectoryProvisionError::StateRootUnavailable)?; + } + Component::Prefix(_) | Component::CurDir | Component::ParentDir => { + return Err(StateDirectoryProvisionError::InvalidPlan); + } + } + } + Ok(current) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn open_directory_at(parent: &File, name: &OsStr) -> Result<File, rustix::io::Errno> { + openat( + parent, + name, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, + Mode::empty(), + ) + .map(File::from) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn created_directory_identity( + parent: &File, + name: &OsStr, +) -> Result<DirectoryIdentity, StateDirectoryProvisionError> { + let status = statat(parent, name, AtFlags::SYMLINK_NOFOLLOW) + .map_err(|_| StateDirectoryProvisionError::DirectoryConflict)?; + validate_directory_status( + FileType::from_raw_mode(status.st_mode).is_dir(), + status.st_uid, + normalize_mode(status.st_mode), + true, + )?; + Ok(DirectoryIdentity { + device: normalize_device(status.st_dev) + .map_err(|_| StateDirectoryProvisionError::UnsafeDirectory)?, + inode: status.st_ino, + }) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn validate_secure_directory( + directory: &File, + exact_owner_mode: bool, +) -> Result<DirectoryIdentity, StateDirectoryProvisionError> { + let status = fstat(directory).map_err(|_| StateDirectoryProvisionError::Filesystem)?; + validate_directory_status( + FileType::from_raw_mode(status.st_mode).is_dir(), + status.st_uid, + normalize_mode(status.st_mode), + exact_owner_mode, + )?; + Ok(DirectoryIdentity { + device: normalize_device(status.st_dev) + .map_err(|_| StateDirectoryProvisionError::UnsafeDirectory)?, + inode: status.st_ino, + }) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn validate_directory_status( + is_directory: bool, + owner: u32, + mode: u32, + exact_owner_mode: bool, +) -> Result<(), StateDirectoryProvisionError> { + let permissions = mode & 0o777; + let permissions_valid = if exact_owner_mode { + permissions == 0o700 + } else { + permissions & 0o022 == 0 && permissions & 0o500 == 0o500 + }; + if is_directory && owner == geteuid().as_raw() && permissions_valid { + Ok(()) + } else { + Err(StateDirectoryProvisionError::UnsafeDirectory) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn cleanup_created_directory(entry: &CreatedDirectory) -> Result<(), ()> { + let current = open_directory_at(&entry.parent, &entry.name).map_err(|_| ())?; + let held_identity = validate_secure_directory(&entry.held, true).map_err(|_| ())?; + let current_identity = validate_secure_directory(&current, true).map_err(|_| ())?; + if held_identity != entry.identity || current_identity != entry.identity { + return Err(()); + } + unlinkat(&entry.parent, &entry.name, AtFlags::REMOVEDIR).map_err(|_| ())?; + entry.parent.sync_all().map_err(|_| ()) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn normalize_mode<T: Into<u32>>(raw: T) -> u32 { + raw.into() +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn normalize_device<T: TryInto<u64>>(raw: T) -> Result<u64, T::Error> { + raw.try_into() +} + +#[cfg(all(test, any(target_os = "linux", target_os = "macos")))] +mod tests { + use std::{ + error::Error as _, + ffi::OsString, + fs, + os::unix::fs::{MetadataExt, PermissionsExt, symlink}, + path::{Path, PathBuf}, + sync::Mutex, + }; + + use tempfile::TempDir; + + use super::{ + ProvisionOperations, RuntimeStateDirectoryPlan, StateDirectoryProvisionError, + provision_with_operations, + }; + use crate::{ + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, + }; + + fn context( + platform: RadrootsPlatform, + profile: RadrootsPathProfile, + root: &Path, + ) -> RuntimeContext { + let environment = match platform { + RadrootsPlatform::Linux => RadrootsHostEnvironment { + xdg_data_home: Some(root.to_path_buf()), + xdg_config_home: Some(root.join("config-root")), + xdg_state_home: Some(root.join("state-root")), + xdg_cache_home: Some(root.join("cache-root")), + xdg_runtime_dir: Some(root.join("runtime-root")), + ..RadrootsHostEnvironment::default() + }, + RadrootsPlatform::Macos => RadrootsHostEnvironment { + home_dir: Some(root.to_path_buf()), + ..RadrootsHostEnvironment::default() + }, + _ => RadrootsHostEnvironment::default(), + }; + let repo_local_root = + matches!(profile, RadrootsPathProfile::RepoLocal).then(|| root.to_path_buf()); + let bootstrap = RuntimeContextBootstrap::new( + profile, + repo_local_root, + if matches!(profile, RadrootsPathProfile::RepoLocal) { + RuntimeContextSource::BootstrapCli + } else { + RuntimeContextSource::SafeDefault + }, + RuntimeContextSource::BootstrapCli, + ) + .expect("bootstrap"); + RuntimeContext::resolve( + &RadrootsPathResolver::new(platform, environment), + bootstrap, + ServiceId::new("myc").expect("service"), + InstanceId::new("primary").expect("instance"), + ) + .expect("context") + } + + fn prepare_state_root(context: &RuntimeContext) -> PathBuf { + let root = context.paths().state_root().to_path_buf(); + fs::create_dir_all(&root).expect("state root"); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).expect("root mode"); + root + } + + #[test] + fn repo_local_creates_only_the_exact_canonical_suffix() { + let temporary = TempDir::new().expect("temporary root"); + let context = context( + RadrootsPlatform::Linux, + RadrootsPathProfile::RepoLocal, + temporary.path(), + ); + let state_root = prepare_state_root(&context); + + context + .state_directory_plan() + .expect("plan") + .provision() + .expect("provision"); + + let expected = state_root.join("services/myc/primary"); + assert_eq!(context.paths().state(), expected); + for directory in [ + state_root.join("services"), + state_root.join("services/myc"), + expected, + ] { + let metadata = fs::metadata(directory).expect("created directory"); + assert!(metadata.is_dir()); + assert_eq!(metadata.permissions().mode() & 0o777, 0o700); + } + assert_eq!( + fs::read_dir(temporary.path()) + .expect("base inventory") + .map(|entry| entry.expect("entry").file_name()) + .collect::<Vec<_>>(), + vec![OsString::from("data")], + ); + } + + #[test] + fn linux_and_macos_interactive_profiles_create_the_exact_suffix() { + let linux = TempDir::new().expect("linux root"); + let linux_context = context( + RadrootsPlatform::Linux, + RadrootsPathProfile::InteractiveUser, + linux.path(), + ); + let linux_root = prepare_state_root(&linux_context); + linux_context + .state_directory_plan() + .expect("linux plan") + .provision() + .expect("linux provision"); + assert_eq!( + linux_context.paths().state(), + linux_root.join("services/myc/primary") + ); + + let macos = TempDir::new().expect("macOS home"); + let macos_context = context( + RadrootsPlatform::Macos, + RadrootsPathProfile::InteractiveUser, + macos.path(), + ); + let macos_root = prepare_state_root(&macos_context); + macos_context + .state_directory_plan() + .expect("macOS plan") + .provision() + .expect("macOS provision"); + assert_eq!( + macos_context.paths().state(), + macos_root.join("services/myc/primary") + ); + } + + #[test] + fn existing_directories_are_validated_without_permission_repair() { + let temporary = TempDir::new().expect("temporary root"); + let context = context( + RadrootsPlatform::Linux, + RadrootsPathProfile::RepoLocal, + temporary.path(), + ); + let root = prepare_state_root(&context); + fs::create_dir_all(context.paths().state()).expect("existing suffix"); + fs::set_permissions(root.join("services/myc"), fs::Permissions::from_mode(0o755)) + .expect("safe existing mode"); + fs::set_permissions(context.paths().state(), fs::Permissions::from_mode(0o770)) + .expect("unsafe mode"); + + assert_eq!( + context.state_directory_plan().expect("plan").provision(), + Err(StateDirectoryProvisionError::UnsafeDirectory) + ); + assert_eq!( + fs::metadata(context.paths().state()) + .expect("state metadata") + .permissions() + .mode() + & 0o777, + 0o770 + ); + assert_eq!( + fs::metadata(root.join("services/myc")) + .expect("service metadata") + .permissions() + .mode() + & 0o777, + 0o755 + ); + } + + #[test] + fn symlink_entries_are_rejected_without_following_or_repairing() { + let temporary = TempDir::new().expect("temporary root"); + let context = context( + RadrootsPlatform::Linux, + RadrootsPathProfile::RepoLocal, + temporary.path(), + ); + let root = prepare_state_root(&context); + let outside = temporary.path().join("outside"); + fs::create_dir(&outside).expect("outside"); + symlink(&outside, root.join("services")).expect("symlink"); + + assert_eq!( + context.state_directory_plan().expect("plan").provision(), + Err(StateDirectoryProvisionError::DirectoryConflict) + ); + assert!( + outside + .read_dir() + .expect("outside inventory") + .next() + .is_none() + ); + } + + #[test] + fn service_host_is_existing_only() { + let temporary = TempDir::new().expect("temporary root"); + let plan = RuntimeStateDirectoryPlan { + profile: RadrootsPathProfile::ServiceHost, + state_root: temporary.path().join("state-root"), + service: ServiceId::new("myc").expect("service"), + instance: InstanceId::new("primary").expect("instance"), + }; + fs::create_dir(&plan.state_root).expect("state root"); + fs::set_permissions(&plan.state_root, fs::Permissions::from_mode(0o700)) + .expect("root mode"); + + assert_eq!( + plan.provision(), + Err(StateDirectoryProvisionError::MissingDirectory) + ); + assert!(!plan.state_root.join("services").exists()); + + fs::create_dir_all(plan.state_root.join("services/myc/primary")) + .expect("preprovisioned suffix"); + for directory in [ + plan.state_root.join("services"), + plan.state_root.join("services/myc"), + plan.state_root.join("services/myc/primary"), + ] { + fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).expect("suffix mode"); + } + plan.provision().expect("existing-only validation"); + } + + struct FailingOperations { + fail_after: usize, + } + + impl ProvisionOperations for FailingOperations { + fn after_create(&self, component_index: usize) -> Result<(), StateDirectoryProvisionError> { + if component_index == self.fail_after { + Err(StateDirectoryProvisionError::Filesystem) + } else { + Ok(()) + } + } + } + + #[test] + fn partial_creation_failure_removes_only_exact_created_identities() { + let temporary = TempDir::new().expect("temporary root"); + let context = context( + RadrootsPlatform::Linux, + RadrootsPathProfile::RepoLocal, + temporary.path(), + ); + let root = prepare_state_root(&context); + let plan = context.state_directory_plan().expect("plan"); + + assert_eq!( + provision_with_operations(&plan, &FailingOperations { fail_after: 1 }), + Err(StateDirectoryProvisionError::Filesystem) + ); + assert!(!root.join("services").exists()); + } + + struct ReplacingOperations { + created: PathBuf, + displaced: PathBuf, + ran: Mutex<bool>, + } + + impl ProvisionOperations for ReplacingOperations { + fn after_create(&self, component_index: usize) -> Result<(), StateDirectoryProvisionError> { + if component_index == 0 { + fs::rename(&self.created, &self.displaced).expect("displace created directory"); + fs::create_dir(&self.created).expect("replacement directory"); + fs::set_permissions(&self.created, fs::Permissions::from_mode(0o700)) + .expect("replacement mode"); + *self.ran.lock().expect("replacement flag") = true; + return Err(StateDirectoryProvisionError::Filesystem); + } + Ok(()) + } + } + + #[test] + fn cleanup_preserves_a_replacement_with_an_unmatched_identity() { + let temporary = TempDir::new().expect("temporary root"); + let context = context( + RadrootsPlatform::Linux, + RadrootsPathProfile::RepoLocal, + temporary.path(), + ); + let root = prepare_state_root(&context); + let operations = ReplacingOperations { + created: root.join("services"), + displaced: root.join("displaced-services"), + ran: Mutex::new(false), + }; + + assert_eq!( + provision_with_operations(&context.state_directory_plan().expect("plan"), &operations,), + Err(StateDirectoryProvisionError::Cleanup) + ); + assert!(*operations.ran.lock().expect("replacement flag")); + assert!(operations.created.is_dir()); + assert_ne!( + fs::metadata(&operations.created) + .expect("replacement") + .ino(), + fs::metadata(&operations.displaced) + .expect("displaced") + .ino(), + ); + } + + struct ReplacingRootOperations { + root: PathBuf, + displaced: PathBuf, + } + + impl ProvisionOperations for ReplacingRootOperations { + fn after_create(&self, component_index: usize) -> Result<(), StateDirectoryProvisionError> { + if component_index == 0 { + fs::rename(&self.root, &self.displaced).expect("displace state root"); + fs::create_dir(&self.root).expect("replacement state root"); + fs::set_permissions(&self.root, fs::Permissions::from_mode(0o700)) + .expect("replacement root mode"); + return Err(StateDirectoryProvisionError::Filesystem); + } + Ok(()) + } + } + + #[test] + fn state_root_replacement_blocks_cleanup_and_preserves_both_identities() { + let temporary = TempDir::new().expect("temporary root"); + let context = context( + RadrootsPlatform::Linux, + RadrootsPathProfile::RepoLocal, + temporary.path(), + ); + let root = prepare_state_root(&context); + let displaced = temporary.path().join("displaced-data"); + let operations = ReplacingRootOperations { + root: root.clone(), + displaced: displaced.clone(), + }; + + assert_eq!( + provision_with_operations(&context.state_directory_plan().expect("plan"), &operations,), + Err(StateDirectoryProvisionError::Cleanup) + ); + assert!(root.is_dir()); + assert!(displaced.join("services").is_dir()); + assert_ne!( + fs::metadata(root).expect("replacement root").ino(), + fs::metadata(displaced).expect("displaced root").ino(), + ); + } + + #[test] + fn plan_and_errors_do_not_render_paths_or_identities() { + let temporary = TempDir::new().expect("temporary root"); + let context = context( + RadrootsPlatform::Linux, + RadrootsPathProfile::RepoLocal, + temporary.path(), + ); + let plan = context.state_directory_plan().expect("plan"); + let rendered = format!("{plan:?}"); + assert!(!rendered.contains(temporary.path().to_string_lossy().as_ref())); + assert!(!rendered.contains("myc")); + assert!(!rendered.contains("primary")); + for failure in [ + StateDirectoryProvisionError::InvalidPlan, + StateDirectoryProvisionError::UnsupportedPlatform, + StateDirectoryProvisionError::UnsupportedProfile, + StateDirectoryProvisionError::StateRootUnavailable, + StateDirectoryProvisionError::MissingDirectory, + StateDirectoryProvisionError::DirectoryConflict, + StateDirectoryProvisionError::UnsafeDirectory, + StateDirectoryProvisionError::Filesystem, + StateDirectoryProvisionError::Cleanup, + ] { + assert!(failure.source().is_none()); + assert!(!format!("{failure:?} {failure}").contains("/")); + } + } +} diff --git a/crates/runtime_paths/src/service.rs b/crates/runtime_paths/src/service.rs @@ -22,6 +22,7 @@ use crate::{InstanceId, ServiceId, roots::RadrootsPaths}; /// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct RadrootsServiceInstancePaths { + state_root: PathBuf, config: PathBuf, state: PathBuf, cache: PathBuf, @@ -40,6 +41,7 @@ impl RadrootsServiceInstancePaths { .join(service.as_str()) .join(instance.as_str()); Self { + state_root: roots.data.clone(), config: roots.config.join(&relative), state: roots.data.join(&relative), cache: roots.cache.join(&relative), @@ -78,6 +80,10 @@ impl RadrootsServiceInstancePaths { pub fn secrets(&self) -> &Path { &self.secrets } + + pub(crate) fn state_root(&self) -> &Path { + &self.state_root + } } #[cfg(test)] diff --git a/crates/runtime_paths/tests/package_boundary.rs b/crates/runtime_paths/tests/package_boundary.rs @@ -11,6 +11,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/error.rs"), include_str!("../src/identifier.rs"), include_str!("../src/platform.rs"), + include_str!("../src/provision.rs"), include_str!("../src/roots.rs"), include_str!("../src/service.rs"), ]; @@ -29,7 +30,7 @@ fn runtime_paths_is_unpublished_lint_governed_and_dependency_bounded() { } assert_eq!( dependency_keys(MANIFEST), - BTreeSet::from(["serde", "thiserror"]) + BTreeSet::from(["rustix", "serde", "thiserror"]) ); assert_eq!( ROOT.lines() @@ -42,6 +43,7 @@ fn runtime_paths_is_unpublished_lint_governed_and_dependency_bounded() { "error", "identifier", "platform", + "provision", "roots", "service", ]) @@ -125,6 +127,10 @@ fn reviewed_api_requires_the_typed_runtime_context_boundary() { "pub struct radroots_runtime_paths::InstanceId", "pub fn radroots_runtime_paths::RuntimeContext::resolve", "pub fn radroots_runtime_paths::RuntimeContext::repo_local_root", + "pub fn radroots_runtime_paths::RuntimeContext::state_directory_plan", + "pub struct radroots_runtime_paths::RuntimeStateDirectoryPlan", + "pub fn radroots_runtime_paths::RuntimeStateDirectoryPlan::provision", + "pub enum radroots_runtime_paths::StateDirectoryProvisionError", "pub fn radroots_runtime_paths::RadrootsPlatform::current", "pub fn radroots_runtime_paths::default_service_instance_artifacts", "pub fn radroots_runtime_paths::service_credential_artifact_path", @@ -140,6 +146,7 @@ fn reviewed_api_requires_the_typed_runtime_context_boundary() { for required in [ "## Example", "## Root Profiles", + "## State Directory Provisioning", "## Common Artifacts", "## Support Caveats", "## Public API Baseline", @@ -156,7 +163,13 @@ fn reviewed_api_requires_the_typed_runtime_context_boundary() { "| SQLite writer lock | `<state>/state.lock` |", "| Local admin socket | `<run>/admin.sock` |", "| Credential artifact | `<secrets>/<validated-credential-name>` |", - "It performs\nno filesystem I/O, creates no directories, and never reads the ambient process", + "Pure path\nresolution never reads the ambient process environment or performs filesystem\nI/O.", + "create only `services/<service>/<instance>`, one descriptor-relative component", + "never changes an existing mode, and identity-checks any cleanup", + "An entry whose identity cannot\nbe proven is preserved and the operation fails closed.", + "For `ServiceHost`, the entire state-directory suffix must already exist.", + "stable path-free unsupported-platform classification", + "does not create any common\nartifact, configuration, cache, log, run, or secrets path", "An absolute\n`XDG_RUNTIME_DIR` is mandatory and has no fallback.", "Linux service-host\non x86_64 and aarch64 is eligible for Tier 1 only after all release gates pass.", "Linux and macOS interactive and explicit repo-local profiles on x86_64 and\naarch64 are developer-target behavior.", @@ -176,6 +189,7 @@ fn reviewed_api_requires_the_typed_runtime_context_boundary() { "pub mod radroots_runtime_paths::error", "pub mod radroots_runtime_paths::identifier", "pub mod radroots_runtime_paths::platform", + "pub mod radroots_runtime_paths::provision", "pub mod radroots_runtime_paths::roots", "pub mod radroots_runtime_paths::service", "radroots_runtime_paths::context::", @@ -183,10 +197,12 @@ fn reviewed_api_requires_the_typed_runtime_context_boundary() { "radroots_runtime_paths::error::", "radroots_runtime_paths::identifier::", "radroots_runtime_paths::platform::", + "radroots_runtime_paths::provision::", "radroots_runtime_paths::roots::", "radroots_runtime_paths::service::", "thiserror::", "serde_json::", + "rustix::", ] { assert!( !PUBLIC_API.contains(forbidden),