lib

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

commit 0c1f6f4d9514155f04a3667ff32bcc5f4ba51487
parent f366e8aca107fe638adb1dc8ffab5f3973964389
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 00:26:22 +0000

service-host: add host error taxonomy

- define stable typed host failure categories and codes
- keep public messages fixed, ASCII, and byte bounded
- preserve internal causes outside safe display surfaces
- test serialization helpers and source redaction

Diffstat:
Acrates/service_host/src/error.rs | 254+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_host/src/lib.rs | 7++++---
Mcrates/service_host/tests/package_boundary.rs | 9++++++++-
3 files changed, 266 insertions(+), 4 deletions(-)

diff --git a/crates/service_host/src/error.rs b/crates/service_host/src/error.rs @@ -0,0 +1,254 @@ +//! Safe public errors with separately preserved internal sources. + +use core::fmt; +use std::error::Error; + +/// Maximum byte length of every public host-error message. +pub const MAX_SAFE_ERROR_MESSAGE_BYTES: usize = 96; + +/// Stable public error codes for host-mechanism failures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostErrorCode { + ConfigDocument, + Lifecycle, + AdminTransport, + OperationsBind, + OperationsServe, + PathContext, + TaskFailure, + InvalidHostContract, +} + +impl HostErrorCode { + /// Returns the stable lowercase wire representation of this code. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ConfigDocument => "host_config_document", + Self::Lifecycle => "host_lifecycle", + Self::AdminTransport => "host_admin_transport", + Self::OperationsBind => "host_operations_bind", + Self::OperationsServe => "host_operations_serve", + Self::PathContext => "host_path_context", + Self::TaskFailure => "host_task_failure", + Self::InvalidHostContract => "invalid_host_contract", + } + } +} + +impl fmt::Display for HostErrorCode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Service-neutral failure categories accepted by the host boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostErrorKind { + ConfigDocument, + Lifecycle, + AdminTransport, + OperationsBind, + OperationsServe, + PathContext, + TaskFailure, + InvalidHostContract, +} + +impl HostErrorKind { + /// Returns the bounded representation safe for public boundaries. + #[must_use] + pub const fn safe_error(self) -> SafeHostError { + match self { + Self::ConfigDocument => SafeHostError::new( + HostErrorCode::ConfigDocument, + "service configuration document is invalid", + ), + Self::Lifecycle => SafeHostError::new( + HostErrorCode::Lifecycle, + "service lifecycle operation failed", + ), + Self::AdminTransport => SafeHostError::new( + HostErrorCode::AdminTransport, + "local administration transport failed", + ), + Self::OperationsBind => SafeHostError::new( + HostErrorCode::OperationsBind, + "operations listener could not be bound", + ), + Self::OperationsServe => SafeHostError::new( + HostErrorCode::OperationsServe, + "operations listener stopped unexpectedly", + ), + Self::PathContext => SafeHostError::new( + HostErrorCode::PathContext, + "service path context is invalid", + ), + Self::TaskFailure => SafeHostError::new( + HostErrorCode::TaskFailure, + "authoritative service task failed", + ), + Self::InvalidHostContract => SafeHostError::new( + HostErrorCode::InvalidHostContract, + "service host contract is invalid", + ), + } + } +} + +/// Error information safe for logs, status, metrics, and wire envelopes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SafeHostError { + code: HostErrorCode, + message: &'static str, +} + +impl SafeHostError { + const fn new(code: HostErrorCode, message: &'static str) -> Self { + assert!(message.len() <= MAX_SAFE_ERROR_MESSAGE_BYTES); + Self { code, message } + } + + /// Returns the typed public code. + #[must_use] + pub const fn code(self) -> HostErrorCode { + self.code + } + + /// Returns the stable string used to serialize the public code. + #[must_use] + pub const fn code_str(self) -> &'static str { + self.code.as_str() + } + + /// Returns the bounded public message. + #[must_use] + pub const fn message(self) -> &'static str { + self.message + } +} + +impl fmt::Display for SafeHostError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +/// A typed host failure whose public display never includes its internal cause. +#[derive(Debug)] +pub struct HostError { + kind: HostErrorKind, + source: Option<Box<dyn Error + Send + Sync + 'static>>, +} + +impl HostError { + /// Creates a host failure without an upstream cause. + #[must_use] + pub const fn new(kind: HostErrorKind) -> Self { + Self { kind, source: None } + } + + /// Creates a host failure while retaining its cause for trusted inspection. + pub fn with_source(kind: HostErrorKind, source: impl Error + Send + Sync + 'static) -> Self { + Self { + kind, + source: Some(Box::new(source)), + } + } + + /// Returns the service-neutral failure category. + #[must_use] + pub const fn kind(&self) -> HostErrorKind { + self.kind + } + + /// Returns the bounded representation safe for public boundaries. + #[must_use] + pub const fn safe_error(&self) -> SafeHostError { + self.kind.safe_error() + } +} + +impl fmt::Display for HostError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.safe_error().fmt(formatter) + } +} + +impl Error for HostError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source + .as_deref() + .map(|source| source as &(dyn Error + 'static)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug)] + struct SensitiveCause; + + impl fmt::Display for SensitiveCause { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("secret upstream detail") + } + } + + impl Error for SensitiveCause {} + + #[test] + fn display_is_safe_while_source_is_preserved() { + let error = HostError::with_source(HostErrorKind::ConfigDocument, SensitiveCause); + + assert_eq!( + error.to_string(), + "host_config_document: service configuration document is invalid" + ); + assert!(!error.to_string().contains("secret")); + assert_eq!( + error.source().map(ToString::to_string).as_deref(), + Some("secret upstream detail") + ); + } + + #[test] + fn safe_codes_have_stable_serialization_helpers() { + let expected = [ + (HostErrorKind::ConfigDocument, "host_config_document"), + (HostErrorKind::Lifecycle, "host_lifecycle"), + (HostErrorKind::AdminTransport, "host_admin_transport"), + (HostErrorKind::OperationsBind, "host_operations_bind"), + (HostErrorKind::OperationsServe, "host_operations_serve"), + (HostErrorKind::PathContext, "host_path_context"), + (HostErrorKind::TaskFailure, "host_task_failure"), + (HostErrorKind::InvalidHostContract, "invalid_host_contract"), + ]; + + for (kind, code) in expected { + let safe = kind.safe_error(); + assert_eq!(safe.code_str(), code); + assert_eq!(safe.code().to_string(), code); + } + } + + #[test] + fn all_public_messages_are_nonempty_bounded_and_fixed() { + for kind in [ + HostErrorKind::ConfigDocument, + HostErrorKind::Lifecycle, + HostErrorKind::AdminTransport, + HostErrorKind::OperationsBind, + HostErrorKind::OperationsServe, + HostErrorKind::PathContext, + HostErrorKind::TaskFailure, + HostErrorKind::InvalidHostContract, + ] { + let safe = kind.safe_error(); + assert!(!safe.message().is_empty()); + assert!(safe.message().len() <= MAX_SAFE_ERROR_MESSAGE_BYTES); + assert!(safe.message().is_ascii()); + } + } +} diff --git a/crates/service_host/src/lib.rs b/crates/service_host/src/lib.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] //! Reusable, service-neutral host mechanics for Radroots services. -//! -//! This crate is intentionally empty while its public modules are introduced -//! as independently reviewed contract checkpoints. + +pub mod error; + +pub use error::{HostError, HostErrorCode, HostErrorKind, SafeHostError}; diff --git a/crates/service_host/tests/package_boundary.rs b/crates/service_host/tests/package_boundary.rs @@ -18,7 +18,14 @@ fn service_host_is_unpublished_lint_governed_and_dependency_free() { } assert_eq!(dependency_keys(MANIFEST), BTreeSet::new()); - assert!(!ROOT.contains("pub mod ")); + assert_eq!(public_modules(ROOT), BTreeSet::from(["error"])); +} + +fn public_modules(root: &str) -> BTreeSet<&str> { + root.lines() + .filter_map(|line| line.strip_prefix("pub mod ")) + .filter_map(|module| module.strip_suffix(';')) + .collect() } fn dependency_keys(manifest: &str) -> BTreeSet<&str> {