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:
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),