lib

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

commit e1354dc3456d8465ff5e2e6a0cf77fea222221e6
parent a646d918daedbf60e17d90d77c045d127759a243
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 05:16:08 +0000

service-host: close admin identifier grammar

- restrict operation and correlation IDs to the governed ASCII vocabulary
- replace raw Debug and Display exposure with redacted diagnostics
- retain explicit borrowed access for trusted protocol serialization
- verify exhaustive grammar, contract, API, Clippy, doctest, and Rustdoc gates

Diffstat:
Mcontracts/api_baselines/radroots_service_host.txt | 8++++++--
Mcrates/service_host/README.md | 6++++++
Mcrates/service_host/src/admin/model.rs | 115++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mcrates/service_host/tests/package_boundary.rs | 10++++++++++
4 files changed, 118 insertions(+), 21 deletions(-)

diff --git a/contracts/api_baselines/radroots_service_host.txt b/contracts/api_baselines/radroots_service_host.txt @@ -50,6 +50,10 @@ pub radroots_service_host::AdminHttpMethod::Post pub enum radroots_service_host::AdminIdentifierError pub radroots_service_host::AdminIdentifierError::Empty pub radroots_service_host::AdminIdentifierError::Empty::field: radroots_service_host::AdminIdentifierField +pub radroots_service_host::AdminIdentifierError::InvalidCharacter +pub radroots_service_host::AdminIdentifierError::InvalidCharacter::field: radroots_service_host::AdminIdentifierField +pub radroots_service_host::AdminIdentifierError::InvalidFirstCharacter +pub radroots_service_host::AdminIdentifierError::InvalidFirstCharacter::field: radroots_service_host::AdminIdentifierField pub radroots_service_host::AdminIdentifierError::TooLong pub radroots_service_host::AdminIdentifierError::TooLong::field: radroots_service_host::AdminIdentifierField impl core::error::Error for radroots_service_host::AdminIdentifierError @@ -644,7 +648,7 @@ pub struct radroots_service_host::AdminCorrelationId(_) impl radroots_service_host::AdminCorrelationId pub fn radroots_service_host::AdminCorrelationId::as_str(&self) -> &str pub fn radroots_service_host::AdminCorrelationId::new(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_service_host::AdminIdentifierError> -impl core::fmt::Display for radroots_service_host::AdminCorrelationId +impl core::fmt::Debug for radroots_service_host::AdminCorrelationId pub fn radroots_service_host::AdminCorrelationId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl serde_core::ser::Serialize for radroots_service_host::AdminCorrelationId pub fn radroots_service_host::AdminCorrelationId::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer @@ -697,7 +701,7 @@ pub struct radroots_service_host::AdminOperationId(_) impl radroots_service_host::AdminOperationId pub fn radroots_service_host::AdminOperationId::as_str(&self) -> &str pub fn radroots_service_host::AdminOperationId::new(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_service_host::AdminIdentifierError> -impl core::fmt::Display for radroots_service_host::AdminOperationId +impl core::fmt::Debug for radroots_service_host::AdminOperationId pub fn radroots_service_host::AdminOperationId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl serde_core::ser::Serialize for radroots_service_host::AdminOperationId pub fn radroots_service_host::AdminOperationId::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer diff --git a/crates/service_host/README.md b/crates/service_host/README.md @@ -106,6 +106,12 @@ retained string. Bounded wire strings use validating Serde visitors, so the host does not create a second prevalidation copy of identifiers, safe messages, reason codes, task names, routes, or metric vocabulary. +Administration operation and correlation identifiers are closed ASCII values +of 1 through 128 bytes. Their first byte is alphanumeric; later bytes are +alphanumeric or `.`, `_`, `:`, or `-`. Ordinary `Debug` is redacted and no +`Display` implementation exposes the retained value. Trusted protocol code +uses the explicit borrowed `as_str` accessor for serialization. + ## Process and runtime ownership The crate does not parse a CLI, read configuration from environment variables, diff --git a/crates/service_host/src/admin/model.rs b/crates/service_host/src/admin/model.rs @@ -33,6 +33,8 @@ impl AdminIdentifierField { pub enum AdminIdentifierError { Empty { field: AdminIdentifierField }, TooLong { field: AdminIdentifierField }, + InvalidFirstCharacter { field: AdminIdentifierField }, + InvalidCharacter { field: AdminIdentifierField }, } impl fmt::Display for AdminIdentifierError { @@ -45,30 +47,20 @@ impl Error for AdminIdentifierError {} macro_rules! admin_identifier { ($name:ident, $field:expr) => { - #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct $name(String); impl $name { pub fn new(value: impl AsRef<str>) -> Result<Self, AdminIdentifierError> { let value = value.as_ref(); let field = $field; - if value.is_empty() { - return Err(AdminIdentifierError::Empty { field }); - } - if value.len() > field.maximum_utf8_bytes() { - return Err(AdminIdentifierError::TooLong { field }); - } + validate_admin_identifier(value, field)?; Ok(Self(value.to_owned())) } fn from_string(value: String) -> Result<Self, AdminIdentifierError> { let field = $field; - if value.is_empty() { - return Err(AdminIdentifierError::Empty { field }); - } - if value.len() > field.maximum_utf8_bytes() { - return Err(AdminIdentifierError::TooLong { field }); - } + validate_admin_identifier(&value, field)?; Ok(Self(value)) } @@ -78,9 +70,12 @@ macro_rules! admin_identifier { } } - impl fmt::Display for $name { + impl fmt::Debug for $name { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) + formatter + .debug_tuple(stringify!($name)) + .field(&"[redacted]") + .finish() } } @@ -131,6 +126,29 @@ macro_rules! admin_identifier { admin_identifier!(AdminOperationId, AdminIdentifierField::OperationId); admin_identifier!(AdminCorrelationId, AdminIdentifierField::CorrelationId); +fn validate_admin_identifier( + value: &str, + field: AdminIdentifierField, +) -> Result<(), AdminIdentifierError> { + let bytes = value.as_bytes(); + let Some((first, remaining)) = bytes.split_first() else { + return Err(AdminIdentifierError::Empty { field }); + }; + if bytes.len() > field.maximum_utf8_bytes() { + return Err(AdminIdentifierError::TooLong { field }); + } + if !first.is_ascii_alphanumeric() { + return Err(AdminIdentifierError::InvalidFirstCharacter { field }); + } + if !remaining + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) + { + return Err(AdminIdentifierError::InvalidCharacter { field }); + } + Ok(()) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AdminErrorCodeError { Empty, @@ -2086,8 +2104,67 @@ mod tests { ); assert!(AdminOperationId::new("x".repeat(ADMIN_OPERATION_ID_MAX_UTF8_BYTES)).is_ok()); assert!(AdminOperationId::new("x".repeat(ADMIN_OPERATION_ID_MAX_UTF8_BYTES + 1)).is_err()); - assert!(AdminCorrelationId::new("é".repeat(64)).is_ok()); - assert!(AdminCorrelationId::new(format!("{}x", "é".repeat(64))).is_err()); + for valid in ["a", "Z9", "a.b_c:d-e"] { + assert!(AdminOperationId::new(valid).is_ok()); + assert!(AdminCorrelationId::new(valid).is_ok()); + } + let maximum = "0".repeat(ADMIN_OPERATION_ID_MAX_UTF8_BYTES); + assert!(AdminOperationId::new(&maximum).is_ok()); + assert!(AdminCorrelationId::new(&maximum).is_ok()); + for invalid in [ + ".first", "_first", ":first", "-first", "é", "a/b", "a b", "a\n", + ] { + assert!(AdminOperationId::new(invalid).is_err()); + assert!(AdminCorrelationId::new(invalid).is_err()); + } + assert_eq!( + AdminOperationId::new("-first").unwrap_err(), + AdminIdentifierError::InvalidFirstCharacter { + field: AdminIdentifierField::OperationId + } + ); + assert_eq!( + AdminCorrelationId::new("a/b").unwrap_err(), + AdminIdentifierError::InvalidCharacter { + field: AdminIdentifierField::CorrelationId + } + ); + let redacted_operation = AdminOperationId::new("private-operation").unwrap(); + let redacted_correlation = AdminCorrelationId::new("private-correlation").unwrap(); + assert_eq!( + format!("{redacted_operation:?}"), + "AdminOperationId(\"[redacted]\")" + ); + assert_eq!( + format!("{redacted_correlation:?}"), + "AdminCorrelationId(\"[redacted]\")" + ); + assert_eq!(redacted_operation.as_str(), "private-operation"); + assert_eq!(redacted_correlation.as_str(), "private-correlation"); + assert_eq!( + serde_json::to_string(&redacted_operation).unwrap(), + "\"private-operation\"" + ); + assert_eq!( + serde_json::to_string(&redacted_correlation).unwrap(), + "\"private-correlation\"" + ); + for byte in 0_u8..=127 { + let character = char::from(byte); + let first = character.to_string(); + let first_allowed = byte.is_ascii_alphanumeric(); + assert_eq!(AdminOperationId::new(&first).is_ok(), first_allowed); + assert_eq!(AdminCorrelationId::new(&first).is_ok(), first_allowed); + + let remaining = format!("a{character}"); + let remaining_allowed = + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-'); + assert_eq!(AdminOperationId::new(&remaining).is_ok(), remaining_allowed); + assert_eq!( + AdminCorrelationId::new(&remaining).is_ok(), + remaining_allowed + ); + } assert!(AdminErrorCode::new("valid_code_2").is_ok()); assert!(AdminErrorCode::new("x".repeat(ADMIN_ERROR_CODE_MAX_UTF8_BYTES)).is_ok()); assert!(AdminErrorCode::new("Invalid-Code").is_err()); @@ -2176,8 +2253,8 @@ mod tests { ] { assert!(!rendered.is_empty()); } - assert_eq!(operation.to_string(), "stable-operation"); - assert_eq!(correlation.to_string(), "safe-correlation"); + assert_eq!(operation.as_str(), "stable-operation"); + assert_eq!(correlation.as_str(), "safe-correlation"); assert!(AdminErrorCode::new("").is_err()); assert!(AdminErrorCode::new("x".repeat(ADMIN_ERROR_CODE_MAX_UTF8_BYTES + 1)).is_err()); assert!(AdminErrorMessage::new("").is_err()); diff --git a/crates/service_host/tests/package_boundary.rs b/crates/service_host/tests/package_boundary.rs @@ -172,6 +172,12 @@ fn documentation_and_reviewed_public_api_are_complete_and_dependency_safe() { "parent.child_token()", "streamed directly into the capped response writer", "validates borrowed UTF-8 before it creates the", + "Administration operation and correlation identifiers are closed ASCII values", + "Their first byte is alphanumeric; later bytes are", + "alphanumeric or `.`, `_`, `:`, or `-`", + "Ordinary `Debug` is redacted and no", + "`Display` implementation exposes the retained value", + "explicit borrowed `as_str` accessor for serialization", ] { assert!(README.contains(required), "README is missing `{required}`"); } @@ -185,6 +191,8 @@ fn documentation_and_reviewed_public_api_are_complete_and_dependency_safe() { "pub struct radroots_service_host::AdminRouter", "pub struct radroots_service_host::OperationsServer", "pub struct radroots_service_host::BoundedMetricsSnapshot", + "pub struct radroots_service_host::AdminOperationId", + "pub struct radroots_service_host::AdminCorrelationId", "pub struct radroots_service_host::ServiceStatus", "pub trait radroots_service_host::MonotonicClock", "pub trait radroots_service_host::EntropySource", @@ -206,6 +214,8 @@ fn documentation_and_reviewed_public_api_are_complete_and_dependency_safe() { "serde_json::", "tokio::", "tokio_util::", + "impl core::fmt::Display for radroots_service_host::AdminOperationId", + "impl core::fmt::Display for radroots_service_host::AdminCorrelationId", ] { assert!( !PUBLIC_API.contains(forbidden),