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:
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::{