lib

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

commit 5169fade97d4ed9cdb5ce690b85d4344e8082a82
parent 0c1f6f4d9514155f04a3667ff32bcc5f4ba51487
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 00:33:45 +0000

runtime-paths: add validated service instance ids

- define lowercase bounded service and instance newtypes
- reject traversal, separators, Unicode, and punctuation
- revalidate identifiers during serde deserialization
- test exact boundaries, display, and wire round trips

Diffstat:
MCargo.lock | 1+
Mcrates/runtime_paths/Cargo.toml | 3+++
Mcrates/runtime_paths/README | 1+
Acrates/runtime_paths/src/identifier.rs | 254+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/runtime_paths/src/lib.rs | 5+++++
5 files changed, 264 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3804,6 +3804,7 @@ name = "radroots_runtime_paths" version = "0.1.0-alpha" dependencies = [ "serde", + "serde_json", "thiserror 1.0.69", ] diff --git a/crates/runtime_paths/Cargo.toml b/crates/runtime_paths/Cargo.toml @@ -15,3 +15,6 @@ readme = "README" [dependencies] serde = { workspace = true, features = ["derive", "std"] } thiserror = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/runtime_paths/README b/crates/runtime_paths/README @@ -8,6 +8,7 @@ runtime path selection and contract helpers for the `radroots` core libraries. * default file-name and bootstrap-path helpers for local runtime layouts; * platform, host-environment, namespace, and path-profile types for path decisions; + * validated service and instance identifiers for canonical service paths; * resolver, override, and runtime-selection helpers that produce structured `RadrootsPaths` outputs; * service and app contract helpers for active profile, override, and root diff --git a/crates/runtime_paths/src/identifier.rs b/crates/runtime_paths/src/identifier.rs @@ -0,0 +1,254 @@ +//! Validated identifiers for canonical service-instance paths. + +use core::{fmt, str::FromStr}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use thiserror::Error; + +/// Maximum encoded length of a service identifier. +pub const SERVICE_ID_MAX_BYTES: usize = 128; + +/// Maximum encoded length of an instance identifier. +pub const INSTANCE_ID_MAX_BYTES: usize = 128; + +/// Identifies which service-instance path component failed validation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ServiceIdentityKind { + Service, + Instance, +} + +impl fmt::Display for ServiceIdentityKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Service => formatter.write_str("service"), + Self::Instance => formatter.write_str("instance"), + } + } +} + +/// Validation failure for a service or instance identifier. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum ServiceIdentityError { + #[error("{kind} identifier must not be empty")] + Empty { kind: ServiceIdentityKind }, + #[error("{kind} identifier exceeds its {maximum}-byte limit")] + TooLong { + kind: ServiceIdentityKind, + maximum: usize, + }, + #[error("{kind} identifier must start and end with a lowercase ASCII letter or digit")] + InvalidBoundary { kind: ServiceIdentityKind }, + #[error("{kind} identifier contains a forbidden character")] + InvalidCharacter { kind: ServiceIdentityKind }, +} + +fn validate( + value: &str, + kind: ServiceIdentityKind, + maximum: usize, +) -> Result<(), ServiceIdentityError> { + if value.is_empty() { + return Err(ServiceIdentityError::Empty { kind }); + } + if value.len() > maximum { + return Err(ServiceIdentityError::TooLong { kind, maximum }); + } + + let is_alphanumeric = |byte: u8| byte.is_ascii_lowercase() || byte.is_ascii_digit(); + let bytes = value.as_bytes(); + if !is_alphanumeric(bytes[0]) || !is_alphanumeric(bytes[bytes.len() - 1]) { + return Err(ServiceIdentityError::InvalidBoundary { kind }); + } + if !bytes + .iter() + .all(|byte| is_alphanumeric(*byte) || matches!(*byte, b'-' | b'_')) + { + return Err(ServiceIdentityError::InvalidCharacter { kind }); + } + + Ok(()) +} + +macro_rules! service_identity { + ($name:ident, $kind:expr, $maximum:ident) => { + #[doc = concat!("A validated canonical ", stringify!($name), " path component.")] + #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] + pub struct $name(String); + + impl $name { + /// Parses and validates a canonical identifier. + pub fn new(value: impl Into<String>) -> Result<Self, ServiceIdentityError> { + let value = value.into(); + validate(&value, $kind, $maximum)?; + Ok(Self(value)) + } + + /// Returns the canonical identifier text. + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Consumes the identifier and returns its canonical text. + #[must_use] + pub fn into_string(self) -> String { + self.0 + } + } + + impl AsRef<str> for $name { + fn as_ref(&self) -> &str { + self.as_str() + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } + } + + impl FromStr for $name { + type Err = ServiceIdentityError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::new(value) + } + } + + impl TryFrom<String> for $name { + type Error = ServiceIdentityError; + + fn try_from(value: String) -> Result<Self, Self::Error> { + Self::new(value) + } + } + + impl From<$name> for String { + fn from(value: $name) -> Self { + value.into_string() + } + } + + impl Serialize for $name { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(serde::de::Error::custom) + } + } + }; +} + +service_identity!( + ServiceId, + ServiceIdentityKind::Service, + SERVICE_ID_MAX_BYTES +); +service_identity!( + InstanceId, + ServiceIdentityKind::Instance, + INSTANCE_ID_MAX_BYTES +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identifiers_accept_exact_boundaries_and_display_canonically() { + for service in ["a", "myc", "farm_service", "service-01"] { + let id = ServiceId::new(service).expect("valid service id"); + assert_eq!(id.as_str(), service); + assert_eq!(id.to_string(), service); + } + for instance in ["0", "default", "north_farm", "west-01"] { + let id = InstanceId::new(instance).expect("valid instance id"); + assert_eq!(id.as_str(), instance); + assert_eq!(id.to_string(), instance); + } + + assert!(ServiceId::new("a".repeat(SERVICE_ID_MAX_BYTES)).is_ok()); + assert!(InstanceId::new("a".repeat(INSTANCE_ID_MAX_BYTES)).is_ok()); + } + + #[test] + fn identifiers_reject_empty_overlong_and_noncanonical_text() { + assert_eq!( + ServiceId::new(""), + Err(ServiceIdentityError::Empty { + kind: ServiceIdentityKind::Service + }) + ); + assert_eq!( + InstanceId::new(""), + Err(ServiceIdentityError::Empty { + kind: ServiceIdentityKind::Instance + }) + ); + assert_eq!( + ServiceId::new("a".repeat(SERVICE_ID_MAX_BYTES + 1)), + Err(ServiceIdentityError::TooLong { + kind: ServiceIdentityKind::Service, + maximum: SERVICE_ID_MAX_BYTES, + }) + ); + assert_eq!( + InstanceId::new("a".repeat(INSTANCE_ID_MAX_BYTES + 1)), + Err(ServiceIdentityError::TooLong { + kind: ServiceIdentityKind::Instance, + maximum: INSTANCE_ID_MAX_BYTES, + }) + ); + + for invalid in ["Myc", "café", "a.b", "a:b", "a b", "a%b", "a/b", r"a\b"] { + assert!(ServiceId::new(invalid).is_err(), "accepted `{invalid}`"); + assert!(InstanceId::new(invalid).is_err(), "accepted `{invalid}`"); + } + for invalid in ["-a", "a-", "_a", "a_"] { + assert!(ServiceId::new(invalid).is_err(), "accepted `{invalid}`"); + assert!(InstanceId::new(invalid).is_err(), "accepted `{invalid}`"); + } + } + + #[test] + fn identifiers_reject_traversal_and_separators() { + for invalid in [".", "..", "../a", "a/../b", r"..\a", "%2e%2e", "a//b"] { + assert!(ServiceId::new(invalid).is_err(), "accepted `{invalid}`"); + assert!(InstanceId::new(invalid).is_err(), "accepted `{invalid}`"); + } + } + + #[test] + fn serde_round_trips_revalidate_identifiers() { + let service = ServiceId::new("myc").expect("service id"); + let encoded = serde_json::to_string(&service).expect("serialize service id"); + assert_eq!(encoded, "\"myc\""); + assert_eq!( + serde_json::from_str::<ServiceId>(&encoded).expect("deserialize service id"), + service + ); + + let instance = InstanceId::new("default-01").expect("instance id"); + let encoded = serde_json::to_string(&instance).expect("serialize instance id"); + assert_eq!( + serde_json::from_str::<InstanceId>(&encoded).expect("deserialize instance id"), + instance + ); + + assert!(serde_json::from_str::<ServiceId>("\"../myc\"").is_err()); + assert!(serde_json::from_str::<InstanceId>("\"UPPER\"").is_err()); + } +} diff --git a/crates/runtime_paths/src/lib.rs b/crates/runtime_paths/src/lib.rs @@ -2,6 +2,7 @@ pub mod conventions; pub mod error; +pub mod identifier; pub mod namespace; pub mod platform; pub mod roots; @@ -23,6 +24,10 @@ pub use conventions::{ default_shared_runtime_store_root_from_shared_accounts_data_root, }; pub use error::RadrootsRuntimePathsError; +pub use identifier::{ + INSTANCE_ID_MAX_BYTES, InstanceId, SERVICE_ID_MAX_BYTES, ServiceId, ServiceIdentityError, + ServiceIdentityKind, +}; pub use namespace::{RadrootsRuntimeNamespace, RadrootsRuntimeNamespaceKind}; pub use platform::{RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPlatform}; pub use roots::{RadrootsPathOverrides, RadrootsPathResolver, RadrootsPaths};