lib

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

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:
Mcrates/runtime_paths/src/conventions.rs | 105++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/runtime_paths/src/lib.rs | 7+++++--
Mcrates/runtime_paths/src/namespace.rs | 94++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/runtime_paths/src/service.rs | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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