commit 012b97e8a1db8fe51a43ef74cbcf104bfac2344d
parent 7b4d584fb0f7251528f3433c384b71966a9ab91d
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 07:05:24 +0000
runtime-paths: derive service instance paths
- require validated service and instance identities for derivation
- map every root class below one canonical instance namespace
- seal constructed path sets behind immutable path accessors
- cover Linux XDG service-host and repo-local path vectors
Diffstat:
4 files changed, 345 insertions(+), 11 deletions(-)
diff --git a/crates/runtime_paths/src/conventions.rs b/crates/runtime_paths/src/conventions.rs
@@ -1,8 +1,9 @@
use std::path::{Path, PathBuf};
use crate::{
- RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, RadrootsRuntimeNamespace,
- RadrootsRuntimePathsError,
+ InstanceId, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver,
+ RadrootsRuntimeNamespace, RadrootsRuntimePathsError, RadrootsServiceInstanceNamespace,
+ RadrootsServiceInstancePaths, ServiceId,
};
pub const DEFAULT_CONFIG_FILE_NAME: &str = "config.toml";
@@ -23,6 +24,20 @@ pub struct RadrootsBootstrapPaths {
pub identity_path: PathBuf,
}
+pub fn default_service_instance_paths(
+ resolver: &RadrootsPathResolver,
+ profile: RadrootsPathProfile,
+ overrides: &RadrootsPathOverrides,
+ service_id: &ServiceId,
+ instance_id: &InstanceId,
+) -> Result<RadrootsServiceInstancePaths, RadrootsRuntimePathsError> {
+ let namespace = RadrootsServiceInstanceNamespace::new(service_id.clone(), instance_id.clone());
+ let roots = resolver.resolve(profile, overrides)?;
+ Ok(RadrootsServiceInstancePaths::from_resolved_roots(
+ &roots, &namespace,
+ ))
+}
+
pub fn default_namespaced_bootstrap_paths(
resolver: &RadrootsPathResolver,
profile: RadrootsPathProfile,
@@ -128,7 +143,7 @@ mod tests {
DEFAULT_SERVICE_IDENTITY_FILE_NAME, DEFAULT_SHARED_GEONAMES_NAMESPACE,
DEFAULT_SHARED_IDENTITY_FILE_NAME, DEFAULT_SHARED_RUNTIME_STORE_DB_FILE_NAME,
DEFAULT_SHARED_RUNTIME_STORE_NAMESPACE, default_namespaced_bootstrap_paths,
- default_shared_geonames_database_file_name,
+ default_service_instance_paths, default_shared_geonames_database_file_name,
default_shared_geonames_database_path_from_cache_root,
default_shared_geonames_root_from_cache_root, default_shared_identity_path,
default_shared_runtime_logs_dir, default_shared_runtime_store_database_path_from_data_root,
@@ -136,6 +151,90 @@ mod tests {
default_shared_runtime_store_root_from_data_root,
default_shared_runtime_store_root_from_shared_accounts_data_root,
};
+ use crate::{InstanceId, ServiceId};
+
+ #[test]
+ fn service_instance_paths_are_exact_for_linux_and_xdg_profiles() {
+ let service_id = ServiceId::new("myc").expect("service id");
+ let instance_id = InstanceId::new("primary").expect("instance id");
+ let service_host = crate::RadrootsPathResolver::new(
+ RadrootsPlatform::Linux,
+ RadrootsHostEnvironment::default(),
+ );
+ let service_paths = default_service_instance_paths(
+ &service_host,
+ crate::RadrootsPathProfile::ServiceHost,
+ &crate::RadrootsPathOverrides::default(),
+ &service_id,
+ &instance_id,
+ )
+ .expect("service-host paths");
+ assert_eq!(
+ service_paths.config(),
+ PathBuf::from("/etc/radroots/services/myc/primary")
+ );
+ assert_eq!(
+ service_paths.state(),
+ PathBuf::from("/var/lib/radroots/services/myc/primary")
+ );
+ assert_eq!(
+ service_paths.cache(),
+ PathBuf::from("/var/cache/radroots/services/myc/primary")
+ );
+ assert_eq!(
+ service_paths.logs(),
+ PathBuf::from("/var/log/radroots/services/myc/primary")
+ );
+ assert_eq!(
+ service_paths.run(),
+ PathBuf::from("/run/radroots/services/myc/primary")
+ );
+ assert_eq!(
+ service_paths.secrets(),
+ PathBuf::from("/etc/radroots/secrets/services/myc/primary")
+ );
+
+ let interactive = crate::RadrootsPathResolver::new(
+ RadrootsPlatform::Linux,
+ RadrootsHostEnvironment {
+ home_dir: Some(PathBuf::from("/home/treesap")),
+ xdg_runtime_dir: Some(PathBuf::from("/run/user/1000")),
+ ..RadrootsHostEnvironment::default()
+ },
+ );
+ let interactive_paths = default_service_instance_paths(
+ &interactive,
+ crate::RadrootsPathProfile::InteractiveUser,
+ &crate::RadrootsPathOverrides::default(),
+ &service_id,
+ &instance_id,
+ )
+ .expect("XDG paths");
+ assert_eq!(
+ interactive_paths.config(),
+ PathBuf::from("/home/treesap/.config/radroots/services/myc/primary")
+ );
+ assert_eq!(
+ interactive_paths.state(),
+ PathBuf::from("/home/treesap/.local/share/radroots/services/myc/primary")
+ );
+ assert_eq!(
+ interactive_paths.cache(),
+ PathBuf::from("/home/treesap/.cache/radroots/services/myc/primary")
+ );
+ assert_eq!(
+ interactive_paths.logs(),
+ PathBuf::from("/home/treesap/.local/state/radroots/logs/services/myc/primary")
+ );
+ assert_eq!(
+ interactive_paths.run(),
+ PathBuf::from("/run/user/1000/radroots/services/myc/primary")
+ );
+ assert_eq!(
+ interactive_paths.secrets(),
+ PathBuf::from("/home/treesap/.config/radroots/secrets/services/myc/primary")
+ );
+ }
#[test]
fn namespaced_bootstrap_paths_use_canonical_interactive_roots() {
diff --git a/crates/runtime_paths/src/lib.rs b/crates/runtime_paths/src/lib.rs
@@ -14,7 +14,7 @@ pub use conventions::{
DEFAULT_SHARED_GEONAMES_NAMESPACE_VALUE, DEFAULT_SHARED_IDENTITY_FILE_NAME,
DEFAULT_SHARED_RUNTIME_STORE_DB_FILE_NAME, DEFAULT_SHARED_RUNTIME_STORE_NAMESPACE,
DEFAULT_SHARED_RUNTIME_STORE_NAMESPACE_KIND, DEFAULT_SHARED_RUNTIME_STORE_NAMESPACE_VALUE,
- RadrootsBootstrapPaths, default_namespaced_bootstrap_paths,
+ RadrootsBootstrapPaths, default_namespaced_bootstrap_paths, default_service_instance_paths,
default_shared_geonames_database_file_name,
default_shared_geonames_database_path_from_cache_root,
default_shared_geonames_root_from_cache_root, default_shared_identity_path,
@@ -28,13 +28,16 @@ pub use identifier::{
INSTANCE_ID_MAX_BYTES, InstanceId, SERVICE_ID_MAX_BYTES, ServiceId, ServiceIdentityError,
ServiceIdentityKind,
};
-pub use namespace::{RadrootsRuntimeNamespace, RadrootsRuntimeNamespaceKind};
+pub use namespace::{
+ RadrootsRuntimeNamespace, RadrootsRuntimeNamespaceKind, RadrootsServiceInstanceNamespace,
+};
pub use platform::{RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPlatform};
pub use roots::{RadrootsPathOverrides, RadrootsPathResolver, RadrootsPaths};
pub use service::{
RadrootsRuntimePathConfigEntry, RadrootsRuntimePathPolicyContract,
RadrootsRuntimePathSelection, RadrootsRuntimePathSelectionError,
RadrootsRuntimeSelectionContract, RadrootsRuntimeSelectionOverrideContract,
+ RadrootsServiceInstancePaths,
};
#[cfg(test)]
diff --git a/crates/runtime_paths/src/namespace.rs b/crates/runtime_paths/src/namespace.rs
@@ -1,6 +1,55 @@
use std::path::PathBuf;
-use crate::RadrootsRuntimePathsError;
+use crate::{InstanceId, RadrootsRuntimePathsError, ServiceId, ServiceIdentityError};
+
+/// A validated canonical `services/<service>/<instance>` namespace.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsServiceInstanceNamespace {
+ service_id: ServiceId,
+ instance_id: InstanceId,
+}
+
+impl RadrootsServiceInstanceNamespace {
+ /// Constructs a namespace from already validated identities.
+ #[must_use]
+ pub fn new(service_id: ServiceId, instance_id: InstanceId) -> Self {
+ Self {
+ service_id,
+ instance_id,
+ }
+ }
+
+ /// Parses both canonical path components before constructing a namespace.
+ pub fn parse(
+ service_id: impl Into<String>,
+ instance_id: impl Into<String>,
+ ) -> Result<Self, ServiceIdentityError> {
+ Ok(Self::new(
+ ServiceId::new(service_id)?,
+ InstanceId::new(instance_id)?,
+ ))
+ }
+
+ /// Returns the validated service identity.
+ #[must_use]
+ pub fn service_id(&self) -> &ServiceId {
+ &self.service_id
+ }
+
+ /// Returns the validated instance identity.
+ #[must_use]
+ pub fn instance_id(&self) -> &InstanceId {
+ &self.instance_id
+ }
+
+ /// Returns the canonical relative namespace path.
+ #[must_use]
+ pub fn relative_path(&self) -> PathBuf {
+ PathBuf::from(RadrootsRuntimeNamespaceKind::Service.path_segment())
+ .join(self.service_id.as_str())
+ .join(self.instance_id.as_str())
+ }
+}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RadrootsRuntimeNamespaceKind {
@@ -89,8 +138,12 @@ fn validate_component(value: &str) -> Result<(), RadrootsRuntimePathsError> {
mod tests {
use std::path::PathBuf;
- use super::{RadrootsRuntimeNamespace, RadrootsRuntimeNamespaceKind};
- use crate::RadrootsRuntimePathsError;
+ use super::{
+ RadrootsRuntimeNamespace, RadrootsRuntimeNamespaceKind, RadrootsServiceInstanceNamespace,
+ };
+ use crate::{
+ InstanceId, RadrootsRuntimePathsError, ServiceId, ServiceIdentityError, ServiceIdentityKind,
+ };
#[test]
fn namespace_kind_path_segments_are_canonical() {
@@ -145,4 +198,39 @@ mod tests {
);
}
}
+
+ #[test]
+ fn service_instance_namespace_requires_both_validated_identities() {
+ let namespace = RadrootsServiceInstanceNamespace::new(
+ ServiceId::new("myc").expect("service id"),
+ InstanceId::new("primary-01").expect("instance id"),
+ );
+ assert_eq!(namespace.service_id().as_str(), "myc");
+ assert_eq!(namespace.instance_id().as_str(), "primary-01");
+ assert_eq!(
+ namespace.relative_path(),
+ PathBuf::from("services/myc/primary-01")
+ );
+
+ for invalid in ["../myc", "/absolute", "myc/other", "Myc"] {
+ let error = RadrootsServiceInstanceNamespace::parse(invalid, "primary")
+ .expect_err("invalid service path component");
+ assert_identity_error_kind(error, ServiceIdentityKind::Service);
+ }
+ for invalid in ["../primary", "/absolute", "primary/other", "Primary"] {
+ let error = RadrootsServiceInstanceNamespace::parse("myc", invalid)
+ .expect_err("invalid instance path component");
+ assert_identity_error_kind(error, ServiceIdentityKind::Instance);
+ }
+ }
+
+ fn assert_identity_error_kind(error: ServiceIdentityError, expected: ServiceIdentityKind) {
+ let actual = match error {
+ ServiceIdentityError::Empty { kind }
+ | ServiceIdentityError::TooLong { kind, .. }
+ | ServiceIdentityError::InvalidBoundary { kind }
+ | ServiceIdentityError::InvalidCharacter { kind } => kind,
+ };
+ assert_eq!(actual, expected);
+ }
}
diff --git a/crates/runtime_paths/src/service.rs b/crates/runtime_paths/src/service.rs
@@ -1,13 +1,92 @@
-use std::{ffi::OsString, path::PathBuf};
+use std::{
+ ffi::OsString,
+ path::{Path, PathBuf},
+};
use serde::Serialize;
use thiserror::Error;
use crate::{
- RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, RadrootsPaths,
- RadrootsRuntimeNamespace, RadrootsRuntimePathsError,
+ InstanceId, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, RadrootsPaths,
+ RadrootsRuntimeNamespace, RadrootsRuntimePathsError, RadrootsServiceInstanceNamespace,
+ ServiceId,
};
+/// Canonical directories owned by one validated service instance.
+///
+/// Callers cannot construct this value without the crate's validated identity
+/// and root derivation boundary.
+///
+/// ```compile_fail
+/// use std::path::PathBuf;
+/// use radroots_runtime_paths::RadrootsServiceInstancePaths;
+///
+/// let _forged = RadrootsServiceInstancePaths {
+/// config: PathBuf::from("/tmp/escape"),
+/// state: PathBuf::from("/tmp/escape"),
+/// cache: PathBuf::from("/tmp/escape"),
+/// logs: PathBuf::from("/tmp/escape"),
+/// run: PathBuf::from("/tmp/escape"),
+/// secrets: PathBuf::from("/tmp/escape"),
+/// };
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsServiceInstancePaths {
+ config: PathBuf,
+ state: PathBuf,
+ cache: PathBuf,
+ logs: PathBuf,
+ run: PathBuf,
+ secrets: PathBuf,
+}
+
+impl RadrootsServiceInstancePaths {
+ pub(crate) fn from_resolved_roots(
+ roots: &RadrootsPaths,
+ namespace: &RadrootsServiceInstanceNamespace,
+ ) -> Self {
+ let relative = namespace.relative_path();
+ Self {
+ config: roots.config.join(&relative),
+ state: roots.data.join(&relative),
+ cache: roots.cache.join(&relative),
+ logs: roots.logs.join(&relative),
+ run: roots.run.join(&relative),
+ secrets: roots.secrets.join(relative),
+ }
+ }
+
+ #[must_use]
+ pub fn config(&self) -> &Path {
+ &self.config
+ }
+
+ #[must_use]
+ pub fn state(&self) -> &Path {
+ &self.state
+ }
+
+ #[must_use]
+ pub fn cache(&self) -> &Path {
+ &self.cache
+ }
+
+ #[must_use]
+ pub fn logs(&self) -> &Path {
+ &self.logs
+ }
+
+ #[must_use]
+ pub fn run(&self) -> &Path {
+ &self.run
+ }
+
+ #[must_use]
+ pub fn secrets(&self) -> &Path {
+ &self.secrets
+ }
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RadrootsRuntimePathSelection {
pub profile: RadrootsPathProfile,
@@ -268,6 +347,23 @@ impl RadrootsRuntimePathSelection {
let roots = resolver.resolve(self.profile, &overrides)?;
Ok(roots.namespaced(&namespace))
}
+
+ pub fn resolve_service_instance_paths(
+ &self,
+ resolver: &RadrootsPathResolver,
+ service_id: &ServiceId,
+ instance_id: &InstanceId,
+ profile_env: &'static str,
+ repo_local_root_env: &'static str,
+ ) -> Result<RadrootsServiceInstancePaths, RadrootsRuntimePathSelectionError> {
+ let namespace =
+ RadrootsServiceInstanceNamespace::new(service_id.clone(), instance_id.clone());
+ let overrides = self.overrides(profile_env, repo_local_root_env)?;
+ let roots = resolver.resolve(self.profile, &overrides)?;
+ Ok(RadrootsServiceInstancePaths::from_resolved_roots(
+ &roots, &namespace,
+ ))
+ }
}
fn parse_profile(
@@ -306,6 +402,7 @@ mod tests {
RadrootsRuntimePathConfigEntry, RadrootsRuntimePathPolicyContract,
RadrootsRuntimePathSelection, RadrootsRuntimePathSelectionError,
};
+ use crate::{InstanceId, ServiceId};
#[test]
fn caller_selection_preserves_profile_and_sources() {
@@ -383,6 +480,53 @@ mod tests {
}
#[test]
+ fn resolve_service_instance_paths_uses_one_repo_local_base() {
+ let selection = RadrootsRuntimePathSelection::caller(
+ RadrootsPathProfile::RepoLocal,
+ Some(PathBuf::from("/repo/.local/radroots")),
+ );
+ let resolver =
+ RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default());
+ let service_id = ServiceId::new("rhi").expect("service id");
+ let instance_id = InstanceId::new("west-01").expect("instance id");
+
+ let paths = selection
+ .resolve_service_instance_paths(
+ &resolver,
+ &service_id,
+ &instance_id,
+ "PROFILE_ENV",
+ "ROOT_ENV",
+ )
+ .expect("service instance paths");
+
+ assert_eq!(
+ paths.config(),
+ PathBuf::from("/repo/.local/radroots/config/services/rhi/west-01")
+ );
+ assert_eq!(
+ paths.state(),
+ PathBuf::from("/repo/.local/radroots/data/services/rhi/west-01")
+ );
+ assert_eq!(
+ paths.cache(),
+ PathBuf::from("/repo/.local/radroots/cache/services/rhi/west-01")
+ );
+ assert_eq!(
+ paths.logs(),
+ PathBuf::from("/repo/.local/radroots/logs/services/rhi/west-01")
+ );
+ assert_eq!(
+ paths.run(),
+ PathBuf::from("/repo/.local/radroots/run/services/rhi/west-01")
+ );
+ assert_eq!(
+ paths.secrets(),
+ PathBuf::from("/repo/.local/radroots/secrets/services/rhi/west-01")
+ );
+ }
+
+ #[test]
fn overrides_require_repo_local_root_for_repo_local_profile() {
let selection = RadrootsRuntimePathSelection::caller(RadrootsPathProfile::RepoLocal, None);
let err = selection