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:
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};