lib

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

commit 8cf291eb540d77ad79738fb0c87136b00f7ac7dd
parent 012b97e8a1db8fe51a43ef74cbcf104bfac2344d
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 07:17:03 +0000

runtime-paths: add service artifact conventions

- derive fixed config, SQLite, lock, and admin socket paths per service instance
- constrain service-owned credential filenames to the sealed secrets namespace
- redact artifact diagnostics and prevent external common-name overrides
- cover exact paths, filename bounds, escape rejection, and public construction boundaries

Diffstat:
Mcrates/runtime_paths/src/conventions.rs | 272+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/runtime_paths/src/lib.rs | 7++++++-
2 files changed, 274 insertions(+), 5 deletions(-)

diff --git a/crates/runtime_paths/src/conventions.rs b/crates/runtime_paths/src/conventions.rs @@ -1,4 +1,9 @@ -use std::path::{Path, PathBuf}; +use std::{ + fmt, + path::{Path, PathBuf}, +}; + +use thiserror::Error; use crate::{ InstanceId, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, @@ -7,6 +12,10 @@ use crate::{ }; pub const DEFAULT_CONFIG_FILE_NAME: &str = "config.toml"; +pub const SERVICE_STATE_DATABASE_FILE_NAME: &str = "state.sqlite"; +pub const SERVICE_STATE_LOCK_FILE_NAME: &str = "state.lock"; +pub const SERVICE_ADMIN_SOCKET_FILE_NAME: &str = "admin.sock"; +pub const SERVICE_CREDENTIAL_ARTIFACT_NAME_MAX_BYTES: usize = 128; pub const DEFAULT_SERVICE_IDENTITY_FILE_NAME: &str = "identity.secret.json"; pub const DEFAULT_SHARED_IDENTITY_FILE_NAME: &str = "default.json"; pub const DEFAULT_SHARED_GEONAMES_NAMESPACE_KIND: &str = "shared"; @@ -24,6 +33,141 @@ pub struct RadrootsBootstrapPaths { pub identity_path: PathBuf, } +/// A validated service-owned credential artifact filename. +#[derive(Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub struct ServiceCredentialArtifactName(String); + +impl ServiceCredentialArtifactName { + pub fn new(value: impl Into<String>) -> Result<Self, ServiceCredentialArtifactNameError> { + let value = value.into(); + if value.is_empty() { + return Err(ServiceCredentialArtifactNameError::Empty); + } + if value.len() > SERVICE_CREDENTIAL_ARTIFACT_NAME_MAX_BYTES { + return Err(ServiceCredentialArtifactNameError::TooLong { + maximum: SERVICE_CREDENTIAL_ARTIFACT_NAME_MAX_BYTES, + }); + } + let bytes = value.as_bytes(); + let is_alphanumeric = |byte: u8| byte.is_ascii_lowercase() || byte.is_ascii_digit(); + if !is_alphanumeric(bytes[0]) || !is_alphanumeric(bytes[bytes.len() - 1]) { + return Err(ServiceCredentialArtifactNameError::InvalidBoundary); + } + if !bytes + .iter() + .all(|byte| is_alphanumeric(*byte) || matches!(*byte, b'.' | b'-' | b'_')) + { + return Err(ServiceCredentialArtifactNameError::InvalidCharacter); + } + Ok(Self(value)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl AsRef<str> for ServiceCredentialArtifactName { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Debug for ServiceCredentialArtifactName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ServiceCredentialArtifactName([redacted])") + } +} + +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum ServiceCredentialArtifactNameError { + #[error("service credential artifact name must not be empty")] + Empty, + #[error("service credential artifact name exceeds its {maximum}-byte limit")] + TooLong { maximum: usize }, + #[error( + "service credential artifact name must start and end with a lowercase ASCII letter or digit" + )] + InvalidBoundary, + #[error("service credential artifact name contains a forbidden character")] + InvalidCharacter, +} + +/// Fixed common artifacts derived for one validated service instance. +/// +/// Callers cannot override the fixed artifact filenames or roots: +/// +/// ```compile_fail +/// use radroots_runtime_paths::RadrootsServiceInstanceArtifacts; +/// +/// let _ = RadrootsServiceInstanceArtifacts { +/// config: "/tmp/alternate.toml".into(), +/// state_database: "/tmp/alternate.sqlite".into(), +/// state_lock: "/tmp/alternate.lock".into(), +/// admin_socket: "/tmp/alternate.sock".into(), +/// }; +/// ``` +#[derive(Clone, PartialEq, Eq)] +pub struct RadrootsServiceInstanceArtifacts { + config: PathBuf, + state_database: PathBuf, + state_lock: PathBuf, + admin_socket: PathBuf, +} + +impl fmt::Debug for RadrootsServiceInstanceArtifacts { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RadrootsServiceInstanceArtifacts([redacted])") + } +} + +impl RadrootsServiceInstanceArtifacts { + fn from_instance_paths(paths: &RadrootsServiceInstancePaths) -> Self { + Self { + config: paths.config().join(DEFAULT_CONFIG_FILE_NAME), + state_database: paths.state().join(SERVICE_STATE_DATABASE_FILE_NAME), + state_lock: paths.state().join(SERVICE_STATE_LOCK_FILE_NAME), + admin_socket: paths.run().join(SERVICE_ADMIN_SOCKET_FILE_NAME), + } + } + + #[must_use] + pub fn config(&self) -> &Path { + &self.config + } + + #[must_use] + pub fn state_database(&self) -> &Path { + &self.state_database + } + + #[must_use] + pub fn state_lock(&self) -> &Path { + &self.state_lock + } + + #[must_use] + pub fn admin_socket(&self) -> &Path { + &self.admin_socket + } +} + +#[must_use] +pub fn default_service_instance_artifacts( + paths: &RadrootsServiceInstancePaths, +) -> RadrootsServiceInstanceArtifacts { + RadrootsServiceInstanceArtifacts::from_instance_paths(paths) +} + +#[must_use] +pub fn service_credential_artifact_path( + paths: &RadrootsServiceInstancePaths, + name: &ServiceCredentialArtifactName, +) -> PathBuf { + paths.secrets().join(name.as_str()) +} + pub fn default_service_instance_paths( resolver: &RadrootsPathResolver, profile: RadrootsPathProfile, @@ -140,9 +284,13 @@ mod tests { }; use super::{ - 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_CONFIG_FILE_NAME, 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, + SERVICE_ADMIN_SOCKET_FILE_NAME, SERVICE_CREDENTIAL_ARTIFACT_NAME_MAX_BYTES, + SERVICE_STATE_DATABASE_FILE_NAME, SERVICE_STATE_LOCK_FILE_NAME, + ServiceCredentialArtifactName, ServiceCredentialArtifactNameError, + default_namespaced_bootstrap_paths, default_service_instance_artifacts, 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, @@ -150,6 +298,7 @@ mod tests { default_shared_runtime_store_database_path_from_shared_accounts_data_root, default_shared_runtime_store_root_from_data_root, default_shared_runtime_store_root_from_shared_accounts_data_root, + service_credential_artifact_path, }; use crate::{InstanceId, ServiceId}; @@ -237,6 +386,121 @@ mod tests { } #[test] + fn service_instance_artifacts_use_only_fixed_common_filenames() { + assert_eq!(DEFAULT_CONFIG_FILE_NAME, "config.toml"); + assert_eq!(SERVICE_STATE_DATABASE_FILE_NAME, "state.sqlite"); + assert_eq!(SERVICE_STATE_LOCK_FILE_NAME, "state.lock"); + assert_eq!(SERVICE_ADMIN_SOCKET_FILE_NAME, "admin.sock"); + + let resolver = crate::RadrootsPathResolver::new( + RadrootsPlatform::Linux, + RadrootsHostEnvironment::default(), + ); + let paths = default_service_instance_paths( + &resolver, + crate::RadrootsPathProfile::ServiceHost, + &crate::RadrootsPathOverrides::default(), + &ServiceId::new("myc").expect("service id"), + &InstanceId::new("primary").expect("instance id"), + ) + .expect("instance paths"); + let artifacts = default_service_instance_artifacts(&paths); + + assert_eq!( + artifacts.config(), + PathBuf::from("/etc/radroots/services/myc/primary/config.toml") + ); + assert_eq!( + artifacts.state_database(), + PathBuf::from("/var/lib/radroots/services/myc/primary/state.sqlite") + ); + assert_eq!( + artifacts.state_lock(), + PathBuf::from("/var/lib/radroots/services/myc/primary/state.lock") + ); + assert_eq!( + artifacts.admin_socket(), + PathBuf::from("/run/radroots/services/myc/primary/admin.sock") + ); + } + + #[test] + fn credential_artifact_names_are_validated_and_remain_outside_state() { + let resolver = crate::RadrootsPathResolver::new( + RadrootsPlatform::Linux, + RadrootsHostEnvironment::default(), + ); + let paths = default_service_instance_paths( + &resolver, + crate::RadrootsPathProfile::ServiceHost, + &crate::RadrootsPathOverrides::default(), + &ServiceId::new("rhi").expect("service id"), + &InstanceId::new("default").expect("instance id"), + ) + .expect("instance paths"); + let name = + ServiceCredentialArtifactName::new("identity.secret.json").expect("credential name"); + assert_eq!( + format!("{name:?}"), + "ServiceCredentialArtifactName([redacted])" + ); + assert!(!format!("{name:?}").contains("identity.secret.json")); + let credential = service_credential_artifact_path(&paths, &name); + assert_eq!( + credential, + PathBuf::from("/etc/radroots/secrets/services/rhi/default/identity.secret.json") + ); + assert!(!credential.starts_with(paths.state())); + + let artifacts = default_service_instance_artifacts(&paths); + let artifacts_debug = format!("{artifacts:?}"); + assert_eq!( + artifacts_debug, + "RadrootsServiceInstanceArtifacts([redacted])" + ); + assert!(!artifacts_debug.contains("/etc/radroots")); + assert!(!artifacts_debug.contains("/var/lib/radroots")); + assert!(!artifacts_debug.contains("/run/radroots")); + + let maximum_name = "a".repeat(SERVICE_CREDENTIAL_ARTIFACT_NAME_MAX_BYTES); + assert_eq!( + ServiceCredentialArtifactName::new(maximum_name.clone()) + .expect("maximum credential name") + .as_str(), + maximum_name + ); + + assert_eq!( + ServiceCredentialArtifactName::new(""), + Err(ServiceCredentialArtifactNameError::Empty) + ); + assert_eq!( + ServiceCredentialArtifactName::new( + "a".repeat(SERVICE_CREDENTIAL_ARTIFACT_NAME_MAX_BYTES + 1) + ), + Err(ServiceCredentialArtifactNameError::TooLong { + maximum: SERVICE_CREDENTIAL_ARTIFACT_NAME_MAX_BYTES, + }) + ); + for invalid in [ + ".", + "..", + "../identity", + "/identity", + "identity/secret", + r"identity\secret", + "Identity", + "identity secret", + "identity-秘密", + ] { + assert!( + ServiceCredentialArtifactName::new(invalid).is_err(), + "accepted invalid credential artifact name `{invalid}`" + ); + } + } + + #[test] fn namespaced_bootstrap_paths_use_canonical_interactive_roots() { let resolver = crate::RadrootsPathResolver::new( RadrootsPlatform::Linux, diff --git a/crates/runtime_paths/src/lib.rs b/crates/runtime_paths/src/lib.rs @@ -14,7 +14,11 @@ 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, default_service_instance_paths, + RadrootsBootstrapPaths, RadrootsServiceInstanceArtifacts, SERVICE_ADMIN_SOCKET_FILE_NAME, + SERVICE_CREDENTIAL_ARTIFACT_NAME_MAX_BYTES, SERVICE_STATE_DATABASE_FILE_NAME, + SERVICE_STATE_LOCK_FILE_NAME, ServiceCredentialArtifactName, + ServiceCredentialArtifactNameError, default_namespaced_bootstrap_paths, + default_service_instance_artifacts, 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, @@ -22,6 +26,7 @@ pub use conventions::{ default_shared_runtime_store_database_path_from_shared_accounts_data_root, default_shared_runtime_store_root_from_data_root, default_shared_runtime_store_root_from_shared_accounts_data_root, + service_credential_artifact_path, }; pub use error::RadrootsRuntimePathsError; pub use identifier::{