lib

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

commit 2edbaa87817ceb00df179813ce306ad940b713d2
parent 9f714b967610d2f44844891ffc000ca6c803cbd6
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 02:44:59 +0000

service-host: add admin envelopes

- define exact v1 mutation success and failure JSON shapes
- validate operation correlation error code and safe message bounds
- reject unknown duplicate null and mismatched contract inputs
- redact generic payloads and internal host error sources

Diffstat:
Acrates/service_host/src/admin/mod.rs | 12++++++++++++
Acrates/service_host/src/admin/model.rs | 1524+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_host/src/lib.rs | 9+++++++++
Mcrates/service_host/tests/package_boundary.rs | 6++++++
4 files changed, 1551 insertions(+), 0 deletions(-)

diff --git a/crates/service_host/src/admin/mod.rs b/crates/service_host/src/admin/mod.rs @@ -0,0 +1,12 @@ +//! Versioned, bounded local-administration protocol models. + +mod model; + +pub use model::{ + ADMIN_CONTRACT_VERSION, ADMIN_CORRELATION_ID_MAX_UTF8_BYTES, ADMIN_ERROR_CODE_MAX_UTF8_BYTES, + ADMIN_ERROR_MESSAGE_MAX_UTF8_BYTES, ADMIN_OPERATION_ID_MAX_UTF8_BYTES, + AdminContractVersionError, AdminCorrelationId, AdminError, AdminErrorCode, AdminErrorCodeError, + AdminErrorMessage, AdminErrorMessageError, AdminFailureResponse, AdminIdentifierError, + AdminIdentifierField, AdminMutationRequest, AdminOperationId, AdminPayloadError, + AdminSuccessResponse, +}; diff --git a/crates/service_host/src/admin/model.rs b/crates/service_host/src/admin/model.rs @@ -0,0 +1,1524 @@ +use core::fmt; +use std::error::Error; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de, ser}; + +use crate::HostError; + +pub const ADMIN_CONTRACT_VERSION: u32 = 1; +pub const ADMIN_OPERATION_ID_MAX_UTF8_BYTES: usize = 128; +pub const ADMIN_CORRELATION_ID_MAX_UTF8_BYTES: usize = 128; +pub const ADMIN_ERROR_CODE_MAX_UTF8_BYTES: usize = 64; +pub const ADMIN_ERROR_MESSAGE_MAX_UTF8_BYTES: usize = 256; + +const UNSUPPORTED_CONTRACT_VERSION_CODE: &str = "unsupported_contract_version"; +const UNSUPPORTED_CONTRACT_VERSION_MESSAGE: &str = "admin contract version is unsupported"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AdminIdentifierField { + OperationId, + CorrelationId, +} + +impl AdminIdentifierField { + const fn maximum_utf8_bytes(self) -> usize { + match self { + Self::OperationId => ADMIN_OPERATION_ID_MAX_UTF8_BYTES, + Self::CorrelationId => ADMIN_CORRELATION_ID_MAX_UTF8_BYTES, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AdminIdentifierError { + Empty { field: AdminIdentifierField }, + TooLong { field: AdminIdentifierField }, +} + +impl fmt::Display for AdminIdentifierError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("admin identifier is outside its required UTF-8 byte bounds") + } +} + +impl Error for AdminIdentifierError {} + +macro_rules! admin_identifier { + ($name:ident, $field:expr) => { + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name(String); + + impl $name { + pub fn new(value: impl Into<String>) -> Result<Self, AdminIdentifierError> { + let value = value.into(); + let field = $field; + if value.is_empty() { + return Err(AdminIdentifierError::Empty { field }); + } + if value.len() > field.maximum_utf8_bytes() { + return Err(AdminIdentifierError::TooLong { field }); + } + Ok(Self(value)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } + } + + 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(de::Error::custom) + } + } + }; +} + +admin_identifier!(AdminOperationId, AdminIdentifierField::OperationId); +admin_identifier!(AdminCorrelationId, AdminIdentifierField::CorrelationId); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AdminErrorCodeError { + Empty, + TooLong, + InvalidCharacter, +} + +impl fmt::Display for AdminErrorCodeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("admin error code must be a bounded lowercase snake identifier") + } +} + +impl Error for AdminErrorCodeError {} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AdminErrorCode(String); + +impl AdminErrorCode { + pub fn new(value: impl Into<String>) -> Result<Self, AdminErrorCodeError> { + let value = value.into(); + if value.is_empty() { + return Err(AdminErrorCodeError::Empty); + } + if value.len() > ADMIN_ERROR_CODE_MAX_UTF8_BYTES { + return Err(AdminErrorCodeError::TooLong); + } + let mut bytes = value.bytes(); + if !matches!(bytes.next(), Some(b'a'..=b'z')) + || !bytes.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_')) + { + return Err(AdminErrorCodeError::InvalidCharacter); + } + Ok(Self(value)) + } + + fn known(value: &'static str) -> Self { + Self(value.to_owned()) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for AdminErrorCode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl Serialize for AdminErrorCode { + 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 AdminErrorCode { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AdminErrorMessageError { + Empty, + TooLong, + ControlCharacter, +} + +impl fmt::Display for AdminErrorMessageError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("admin error message is not a bounded safe message") + } +} + +impl Error for AdminErrorMessageError {} + +pub enum AdminPayloadError { + NullForbidden, + Encoding(serde_json::Error), +} + +impl fmt::Debug for AdminPayloadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::NullForbidden => "AdminPayloadError::NullForbidden", + Self::Encoding(_) => "AdminPayloadError::Encoding(<redacted>)", + }) + } +} + +impl fmt::Display for AdminPayloadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::NullForbidden => "admin payloads may not contain JSON null", + Self::Encoding(_) => "admin payload could not be represented as JSON", + }) + } +} + +impl Error for AdminPayloadError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::NullForbidden => None, + Self::Encoding(error) => Some(error), + } + } +} + +#[derive(Clone, PartialEq, Eq)] +struct NonNullPayload<T>(T); + +impl<T> NonNullPayload<T> +where + T: Serialize, +{ + fn new(value: T) -> Result<Self, AdminPayloadError> { + checked_json_value(&value)?; + Ok(Self(value)) + } +} + +impl<T> Serialize for NonNullPayload<T> +where + T: Serialize, +{ + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + checked_json_value(&self.0) + .map_err(ser::Error::custom)? + .serialize(serializer) + } +} + +impl<'de, T> Deserialize<'de> for NonNullPayload<T> +where + T: Deserialize<'de> + Serialize, +{ + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let value = T::deserialize(NoNullDeserializer(deserializer))?; + Self::new(value).map_err(de::Error::custom) + } +} + +struct NoNullDeserializer<D>(D); + +impl<'de, D> Deserializer<'de> for NoNullDeserializer<D> +where + D: Deserializer<'de>, +{ + type Error = D::Error; + + fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_any(NoNullVisitor(visitor)) + } + + fn deserialize_bool<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_bool(NoNullVisitor(visitor)) + } + + fn deserialize_i8<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_i8(NoNullVisitor(visitor)) + } + + fn deserialize_i16<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_i16(NoNullVisitor(visitor)) + } + + fn deserialize_i32<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_i32(NoNullVisitor(visitor)) + } + + fn deserialize_i64<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_i64(NoNullVisitor(visitor)) + } + + fn deserialize_i128<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_i128(NoNullVisitor(visitor)) + } + + fn deserialize_u8<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_u8(NoNullVisitor(visitor)) + } + + fn deserialize_u16<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_u16(NoNullVisitor(visitor)) + } + + fn deserialize_u32<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_u32(NoNullVisitor(visitor)) + } + + fn deserialize_u64<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_u64(NoNullVisitor(visitor)) + } + + fn deserialize_u128<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_u128(NoNullVisitor(visitor)) + } + + fn deserialize_f32<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_f32(NoNullVisitor(visitor)) + } + + fn deserialize_f64<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_f64(NoNullVisitor(visitor)) + } + + fn deserialize_char<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_char(NoNullVisitor(visitor)) + } + + fn deserialize_str<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_str(NoNullVisitor(visitor)) + } + + fn deserialize_string<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_string(NoNullVisitor(visitor)) + } + + fn deserialize_bytes<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_bytes(NoNullVisitor(visitor)) + } + + fn deserialize_byte_buf<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_byte_buf(NoNullVisitor(visitor)) + } + + fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_option(NoNullVisitor(visitor)) + } + + fn deserialize_unit<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_unit(NoNullVisitor(visitor)) + } + + fn deserialize_unit_struct<V>( + self, + name: &'static str, + visitor: V, + ) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_unit_struct(name, NoNullVisitor(visitor)) + } + + fn deserialize_newtype_struct<V>( + self, + name: &'static str, + visitor: V, + ) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0 + .deserialize_newtype_struct(name, NoNullVisitor(visitor)) + } + + fn deserialize_seq<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_seq(NoNullVisitor(visitor)) + } + + fn deserialize_tuple<V>(self, len: usize, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_tuple(len, NoNullVisitor(visitor)) + } + + fn deserialize_tuple_struct<V>( + self, + name: &'static str, + len: usize, + visitor: V, + ) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0 + .deserialize_tuple_struct(name, len, NoNullVisitor(visitor)) + } + + fn deserialize_map<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_map(NoNullVisitor(visitor)) + } + + fn deserialize_struct<V>( + self, + name: &'static str, + fields: &'static [&'static str], + visitor: V, + ) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0 + .deserialize_struct(name, fields, NoNullVisitor(visitor)) + } + + fn deserialize_enum<V>( + self, + name: &'static str, + variants: &'static [&'static str], + visitor: V, + ) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0 + .deserialize_enum(name, variants, NoNullVisitor(visitor)) + } + + fn deserialize_identifier<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_identifier(NoNullVisitor(visitor)) + } + + fn deserialize_ignored_any<V>(self, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.deserialize_ignored_any(NoNullVisitor(visitor)) + } + + fn is_human_readable(&self) -> bool { + self.0.is_human_readable() + } +} + +struct NoNullVisitor<V>(V); + +impl<'de, V> de::Visitor<'de> for NoNullVisitor<V> +where + V: de::Visitor<'de>, +{ + type Value = V::Value; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.expecting(formatter) + } + + fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_bool(value) + } + + fn visit_i8<E>(self, value: i8) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_i8(value) + } + + fn visit_i16<E>(self, value: i16) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_i16(value) + } + + fn visit_i32<E>(self, value: i32) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_i32(value) + } + + fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_i64(value) + } + + fn visit_i128<E>(self, value: i128) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_i128(value) + } + + fn visit_u8<E>(self, value: u8) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_u8(value) + } + + fn visit_u16<E>(self, value: u16) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_u16(value) + } + + fn visit_u32<E>(self, value: u32) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_u32(value) + } + + fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_u64(value) + } + + fn visit_u128<E>(self, value: u128) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_u128(value) + } + + fn visit_f32<E>(self, value: f32) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_f32(value) + } + + fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_f64(value) + } + + fn visit_char<E>(self, value: char) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_char(value) + } + + fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_str(value) + } + + fn visit_borrowed_str<E>(self, value: &'de str) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_borrowed_str(value) + } + + fn visit_string<E>(self, value: String) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_string(value) + } + + fn visit_bytes<E>(self, value: &[u8]) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_bytes(value) + } + + fn visit_borrowed_bytes<E>(self, value: &'de [u8]) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_borrowed_bytes(value) + } + + fn visit_byte_buf<E>(self, value: Vec<u8>) -> Result<Self::Value, E> + where + E: de::Error, + { + self.0.visit_byte_buf(value) + } + + fn visit_none<E>(self) -> Result<Self::Value, E> + where + E: de::Error, + { + Err(E::custom("admin payloads may not contain JSON null")) + } + + fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error> + where + D: Deserializer<'de>, + { + self.0.visit_some(NoNullDeserializer(deserializer)) + } + + fn visit_unit<E>(self) -> Result<Self::Value, E> + where + E: de::Error, + { + Err(E::custom("admin payloads may not contain JSON null")) + } + + fn visit_newtype_struct<D>(self, deserializer: D) -> Result<Self::Value, D::Error> + where + D: Deserializer<'de>, + { + self.0 + .visit_newtype_struct(NoNullDeserializer(deserializer)) + } + + fn visit_seq<A>(self, sequence: A) -> Result<Self::Value, A::Error> + where + A: de::SeqAccess<'de>, + { + self.0.visit_seq(NoNullSeqAccess(sequence)) + } + + fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error> + where + A: de::MapAccess<'de>, + { + self.0.visit_map(NoNullMapAccess(map)) + } + + fn visit_enum<A>(self, data: A) -> Result<Self::Value, A::Error> + where + A: de::EnumAccess<'de>, + { + self.0.visit_enum(NoNullEnumAccess(data)) + } +} + +struct NoNullSeed<S>(S); + +impl<'de, S> de::DeserializeSeed<'de> for NoNullSeed<S> +where + S: de::DeserializeSeed<'de>, +{ + type Value = S::Value; + + fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error> + where + D: Deserializer<'de>, + { + self.0.deserialize(NoNullDeserializer(deserializer)) + } +} + +struct NoNullSeqAccess<A>(A); + +impl<'de, A> de::SeqAccess<'de> for NoNullSeqAccess<A> +where + A: de::SeqAccess<'de>, +{ + type Error = A::Error; + + fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error> + where + T: de::DeserializeSeed<'de>, + { + self.0.next_element_seed(NoNullSeed(seed)) + } + + fn size_hint(&self) -> Option<usize> { + self.0.size_hint() + } +} + +struct NoNullMapAccess<A>(A); + +impl<'de, A> de::MapAccess<'de> for NoNullMapAccess<A> +where + A: de::MapAccess<'de>, +{ + type Error = A::Error; + + fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error> + where + K: de::DeserializeSeed<'de>, + { + self.0.next_key_seed(seed) + } + + fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error> + where + V: de::DeserializeSeed<'de>, + { + self.0.next_value_seed(NoNullSeed(seed)) + } + + fn size_hint(&self) -> Option<usize> { + self.0.size_hint() + } +} + +struct NoNullEnumAccess<A>(A); + +impl<'de, A> de::EnumAccess<'de> for NoNullEnumAccess<A> +where + A: de::EnumAccess<'de>, +{ + type Error = A::Error; + type Variant = NoNullVariantAccess<A::Variant>; + + fn variant_seed<V>(self, seed: V) -> Result<(V::Value, Self::Variant), Self::Error> + where + V: de::DeserializeSeed<'de>, + { + let (value, variant) = self.0.variant_seed(seed)?; + Ok((value, NoNullVariantAccess(variant))) + } +} + +struct NoNullVariantAccess<A>(A); + +impl<'de, A> de::VariantAccess<'de> for NoNullVariantAccess<A> +where + A: de::VariantAccess<'de>, +{ + type Error = A::Error; + + fn unit_variant(self) -> Result<(), Self::Error> { + self.0.unit_variant() + } + + fn newtype_variant_seed<T>(self, seed: T) -> Result<T::Value, Self::Error> + where + T: de::DeserializeSeed<'de>, + { + self.0.newtype_variant_seed(NoNullSeed(seed)) + } + + fn tuple_variant<V>(self, len: usize, visitor: V) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.tuple_variant(len, NoNullVisitor(visitor)) + } + + fn struct_variant<V>( + self, + fields: &'static [&'static str], + visitor: V, + ) -> Result<V::Value, Self::Error> + where + V: de::Visitor<'de>, + { + self.0.struct_variant(fields, NoNullVisitor(visitor)) + } +} + +fn checked_json_value(value: &impl Serialize) -> Result<serde_json::Value, AdminPayloadError> { + let value = serde_json::to_value(value).map_err(AdminPayloadError::Encoding)?; + if contains_null(&value) { + Err(AdminPayloadError::NullForbidden) + } else { + Ok(value) + } +} + +fn contains_null(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Null => true, + serde_json::Value::Array(values) => values.iter().any(contains_null), + serde_json::Value::Object(values) => values.values().any(contains_null), + serde_json::Value::Bool(_) + | serde_json::Value::Number(_) + | serde_json::Value::String(_) => false, + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AdminErrorMessage(String); + +impl AdminErrorMessage { + pub fn new(value: impl Into<String>) -> Result<Self, AdminErrorMessageError> { + let value = value.into(); + if value.is_empty() { + return Err(AdminErrorMessageError::Empty); + } + if value.len() > ADMIN_ERROR_MESSAGE_MAX_UTF8_BYTES { + return Err(AdminErrorMessageError::TooLong); + } + if value.chars().any(char::is_control) { + return Err(AdminErrorMessageError::ControlCharacter); + } + Ok(Self(value)) + } + + fn known(value: &'static str) -> Self { + Self(value.to_owned()) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for AdminErrorMessage { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl Serialize for AdminErrorMessage { + 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 AdminErrorMessage { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdminError { + code: AdminErrorCode, + message: AdminErrorMessage, +} + +impl AdminError { + #[must_use] + pub const fn new(code: AdminErrorCode, message: AdminErrorMessage) -> Self { + Self { code, message } + } + + #[must_use] + pub fn from_host_error(error: &HostError) -> Self { + let safe = error.safe_error(); + Self { + code: AdminErrorCode::known(safe.code_str()), + message: AdminErrorMessage::known(safe.message()), + } + } + + #[must_use] + pub fn code(&self) -> &AdminErrorCode { + &self.code + } + + #[must_use] + pub fn message(&self) -> &AdminErrorMessage { + &self.message + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct SuccessLiteral(#[serde(with = "success_literal")] ()); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct ContractVersionLiteral(#[serde(with = "contract_version_literal")] ()); + +mod contract_version_literal { + use serde::{Deserialize, Deserializer, Serializer, de}; + + use super::ADMIN_CONTRACT_VERSION; + + pub fn serialize<S>(_: &(), serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_u32(ADMIN_CONTRACT_VERSION) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result<(), D::Error> + where + D: Deserializer<'de>, + { + let received = u32::deserialize(deserializer)?; + if received == ADMIN_CONTRACT_VERSION { + Ok(()) + } else { + Err(de::Error::custom( + "admin response contract version must be 1", + )) + } + } +} + +mod success_literal { + use serde::{Deserialize, Deserializer, Serializer, de}; + + pub fn serialize<S>(_: &(), serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_bool(true) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result<(), D::Error> + where + D: Deserializer<'de>, + { + if bool::deserialize(deserializer)? { + Ok(()) + } else { + Err(de::Error::custom("admin success envelope requires ok=true")) + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct FailureLiteral(#[serde(with = "failure_literal")] ()); + +mod failure_literal { + use serde::{Deserialize, Deserializer, Serializer, de}; + + pub fn serialize<S>(_: &(), serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_bool(false) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result<(), D::Error> + where + D: Deserializer<'de>, + { + if bool::deserialize(deserializer)? { + Err(de::Error::custom( + "admin failure envelope requires ok=false", + )) + } else { + Ok(()) + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +enum OptionalCorrelationId { + #[default] + Absent, + Present(AdminCorrelationId), +} + +impl OptionalCorrelationId { + fn is_absent(&self) -> bool { + matches!(self, Self::Absent) + } + + fn as_option(&self) -> Option<&AdminCorrelationId> { + match self { + Self::Absent => None, + Self::Present(value) => Some(value), + } + } +} + +impl Serialize for OptionalCorrelationId { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + match self { + Self::Absent => serializer.serialize_none(), + Self::Present(value) => value.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for OptionalCorrelationId { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + AdminCorrelationId::deserialize(deserializer).map(Self::Present) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + deny_unknown_fields, + bound( + serialize = "T: Serialize", + deserialize = "T: Deserialize<'de> + Serialize" + ) +)] +pub struct AdminMutationRequest<T> { + contract_version: u32, + operation_id: AdminOperationId, + #[serde(default, skip_serializing_if = "OptionalCorrelationId::is_absent")] + correlation_id: OptionalCorrelationId, + request: NonNullPayload<T>, +} + +impl<T> AdminMutationRequest<T> +where + T: Serialize, +{ + pub fn new( + operation_id: AdminOperationId, + correlation_id: Option<AdminCorrelationId>, + request: T, + ) -> Result<Self, AdminPayloadError> { + Ok(Self { + contract_version: ADMIN_CONTRACT_VERSION, + operation_id, + correlation_id: correlation_id + .map(OptionalCorrelationId::Present) + .unwrap_or_default(), + request: NonNullPayload::new(request)?, + }) + } + + #[must_use] + pub const fn contract_version(&self) -> u32 { + self.contract_version + } + + pub fn validate_contract_version(&self) -> Result<(), AdminContractVersionError> { + if self.contract_version == ADMIN_CONTRACT_VERSION { + Ok(()) + } else { + Err(AdminContractVersionError { + received: self.contract_version, + }) + } + } + + #[must_use] + pub const fn operation_id(&self) -> &AdminOperationId { + &self.operation_id + } + + #[must_use] + pub fn correlation_id(&self) -> Option<&AdminCorrelationId> { + self.correlation_id.as_option() + } + + #[must_use] + pub const fn request(&self) -> &T { + &self.request.0 + } + + #[must_use] + pub fn into_request(self) -> T { + self.request.0 + } +} + +impl<T> fmt::Debug for AdminMutationRequest<T> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AdminMutationRequest") + .field("contract_version", &self.contract_version) + .field("operation_id", &self.operation_id) + .field("correlation_id", &self.correlation_id.as_option()) + .field("request", &"<redacted>") + .finish() + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + deny_unknown_fields, + bound( + serialize = "T: Serialize", + deserialize = "T: Deserialize<'de> + Serialize" + ) +)] +pub struct AdminSuccessResponse<T> { + contract_version: ContractVersionLiteral, + ok: SuccessLiteral, + correlation_id: AdminCorrelationId, + result: NonNullPayload<T>, +} + +impl<T> AdminSuccessResponse<T> +where + T: Serialize, +{ + pub fn new(correlation_id: AdminCorrelationId, result: T) -> Result<Self, AdminPayloadError> { + Ok(Self { + contract_version: ContractVersionLiteral(()), + ok: SuccessLiteral(()), + correlation_id, + result: NonNullPayload::new(result)?, + }) + } + + #[must_use] + pub const fn correlation_id(&self) -> &AdminCorrelationId { + &self.correlation_id + } + + #[must_use] + pub const fn result(&self) -> &T { + &self.result.0 + } + + #[must_use] + pub fn into_result(self) -> T { + self.result.0 + } +} + +impl<T> fmt::Debug for AdminSuccessResponse<T> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AdminSuccessResponse") + .field("contract_version", &ADMIN_CONTRACT_VERSION) + .field("ok", &true) + .field("correlation_id", &self.correlation_id) + .field("result", &"<redacted>") + .finish() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdminFailureResponse { + contract_version: ContractVersionLiteral, + ok: FailureLiteral, + correlation_id: AdminCorrelationId, + error: AdminError, +} + +impl AdminFailureResponse { + #[must_use] + pub const fn new(correlation_id: AdminCorrelationId, error: AdminError) -> Self { + Self { + contract_version: ContractVersionLiteral(()), + ok: FailureLiteral(()), + correlation_id, + error, + } + } + + #[must_use] + pub fn unsupported_contract_version(correlation_id: AdminCorrelationId) -> Self { + Self::new( + correlation_id, + AdminError::new( + AdminErrorCode::known(UNSUPPORTED_CONTRACT_VERSION_CODE), + AdminErrorMessage::known(UNSUPPORTED_CONTRACT_VERSION_MESSAGE), + ), + ) + } + + #[must_use] + pub const fn correlation_id(&self) -> &AdminCorrelationId { + &self.correlation_id + } + + #[must_use] + pub const fn error(&self) -> &AdminError { + &self.error + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AdminContractVersionError { + received: u32, +} + +impl AdminContractVersionError { + #[must_use] + pub const fn received(self) -> u32 { + self.received + } + + #[must_use] + pub fn response(self, correlation_id: AdminCorrelationId) -> AdminFailureResponse { + AdminFailureResponse::unsupported_contract_version(correlation_id) + } +} + +impl fmt::Display for AdminContractVersionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("admin contract version is unsupported") + } +} + +impl Error for AdminContractVersionError {} + +#[cfg(test)] +mod tests { + use std::error::Error; + + use serde::{Deserialize, Serialize}; + + use crate::HostErrorKind; + + use super::*; + + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + struct ExampleRequest { + value: u32, + } + + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + struct ExampleResult { + state: String, + } + + fn operation_id() -> AdminOperationId { + AdminOperationId::new("stable-operation").unwrap() + } + + fn correlation_id() -> AdminCorrelationId { + AdminCorrelationId::new("safe-correlation").unwrap() + } + + #[test] + fn request_and_response_snapshots_are_exact() { + let request = AdminMutationRequest::new( + operation_id(), + Some(correlation_id()), + ExampleRequest { value: 7 }, + ) + .unwrap(); + assert_eq!( + serde_json::to_string(&request).unwrap(), + r#"{"contract_version":1,"operation_id":"stable-operation","correlation_id":"safe-correlation","request":{"value":7}}"# + ); + + let without_correlation = + AdminMutationRequest::new(operation_id(), None, ExampleRequest { value: 7 }).unwrap(); + assert_eq!( + serde_json::to_string(&without_correlation).unwrap(), + r#"{"contract_version":1,"operation_id":"stable-operation","request":{"value":7}}"# + ); + + let success = AdminSuccessResponse::new( + correlation_id(), + ExampleResult { + state: "committed".to_owned(), + }, + ) + .unwrap(); + assert_eq!( + serde_json::to_string(&success).unwrap(), + r#"{"contract_version":1,"ok":true,"correlation_id":"safe-correlation","result":{"state":"committed"}}"# + ); + + let failure = AdminFailureResponse::new( + correlation_id(), + AdminError::new( + AdminErrorCode::new("operation_id_conflict").unwrap(), + AdminErrorMessage::new("operation identity conflicts with prior input").unwrap(), + ), + ); + assert_eq!( + serde_json::to_string(&failure).unwrap(), + r#"{"contract_version":1,"ok":false,"correlation_id":"safe-correlation","error":{"code":"operation_id_conflict","message":"operation identity conflicts with prior input"}}"# + ); + } + + #[test] + fn unknown_version_maps_to_the_stable_failure_response() { + let request: AdminMutationRequest<ExampleRequest> = serde_json::from_str( + r#"{"contract_version":2,"operation_id":"stable-operation","request":{"value":7}}"#, + ) + .unwrap(); + let mismatch = request.validate_contract_version().unwrap_err(); + assert_eq!(mismatch.received(), 2); + let response = mismatch.response(correlation_id()); + assert_eq!( + serde_json::to_string(&response).unwrap(), + r#"{"contract_version":1,"ok":false,"correlation_id":"safe-correlation","error":{"code":"unsupported_contract_version","message":"admin contract version is unsupported"}}"# + ); + } + + #[test] + fn invalid_ids_codes_messages_and_literals_fail_closed() { + assert_eq!( + AdminOperationId::new("").unwrap_err(), + AdminIdentifierError::Empty { + field: AdminIdentifierField::OperationId + } + ); + 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()); + assert!(AdminErrorCode::new("valid_code_2").is_ok()); + assert!(AdminErrorCode::new("Invalid-Code").is_err()); + assert!(AdminErrorMessage::new("x".repeat(ADMIN_ERROR_MESSAGE_MAX_UTF8_BYTES)).is_ok()); + assert!( + AdminErrorMessage::new("x".repeat(ADMIN_ERROR_MESSAGE_MAX_UTF8_BYTES + 1)).is_err() + ); + assert!(AdminErrorMessage::new("unsafe\nmessage").is_err()); + + let wrong_success: Result<AdminSuccessResponse<ExampleResult>, _> = serde_json::from_str( + r#"{"contract_version":1,"ok":false,"correlation_id":"safe-correlation","result":{"state":"committed"}}"#, + ); + assert!(wrong_success.is_err()); + let wrong_failure: Result<AdminFailureResponse, _> = serde_json::from_str( + r#"{"contract_version":1,"ok":true,"correlation_id":"safe-correlation","error":{"code":"valid_code","message":"safe"}}"#, + ); + assert!(wrong_failure.is_err()); + let wrong_version: Result<AdminFailureResponse, _> = serde_json::from_str( + r#"{"contract_version":2,"ok":false,"correlation_id":"safe-correlation","error":{"code":"valid_code","message":"safe"}}"#, + ); + assert!(wrong_version.is_err()); + } + + #[test] + fn duplicate_unknown_and_null_fields_are_rejected() { + for document in [ + r#"{"contract_version":1,"contract_version":1,"operation_id":"stable-operation","request":{"value":7}}"#, + r#"{"contract_version":1,"operation_id":"stable-operation","unknown":true,"request":{"value":7}}"#, + r#"{"contract_version":1,"operation_id":"stable-operation","correlation_id":null,"request":{"value":7}}"#, + r#"{"contract_version":1,"operation_id":"stable-operation","request":{"value":7,"unknown":true}}"#, + ] { + assert!( + serde_json::from_str::<AdminMutationRequest<ExampleRequest>>(document).is_err() + ); + } + + assert!( + serde_json::from_str::<AdminMutationRequest<Option<ExampleRequest>>>( + r#"{"contract_version":1,"operation_id":"stable-operation","request":null}"#, + ) + .is_err() + ); + assert!( + serde_json::from_str::<AdminSuccessResponse<Option<ExampleResult>>>( + r#"{"contract_version":1,"ok":true,"correlation_id":"safe-correlation","result":null}"#, + ) + .is_err() + ); + assert!(matches!( + AdminMutationRequest::new(operation_id(), None, Option::<ExampleRequest>::None), + Err(AdminPayloadError::NullForbidden) + )); + assert!(matches!( + AdminSuccessResponse::new(correlation_id(), Option::<ExampleResult>::None), + Err(AdminPayloadError::NullForbidden) + )); + } + + #[derive(Serialize)] + struct SensitivePayload { + credential: String, + } + + struct NullNormalizingPayload; + + impl Serialize for NullNormalizingPayload { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_str("normalized") + } + } + + impl<'de> Deserialize<'de> for NullNormalizingPayload { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + Option::<de::IgnoredAny>::deserialize(deserializer)?; + Ok(Self) + } + } + + #[test] + fn raw_null_is_rejected_before_a_payload_can_normalize_it() { + assert!( + serde_json::from_str::<AdminMutationRequest<NullNormalizingPayload>>( + r#"{"contract_version":1,"operation_id":"stable-operation","request":null}"#, + ) + .is_err() + ); + assert!( + serde_json::from_str::<AdminSuccessResponse<NullNormalizingPayload>>( + r#"{"contract_version":1,"ok":true,"correlation_id":"safe-correlation","result":null}"#, + ) + .is_err() + ); + } + + #[test] + fn ordinary_debug_redacts_request_and_result_payloads() { + let request = AdminMutationRequest::new( + operation_id(), + Some(correlation_id()), + SensitivePayload { + credential: "secret request credential".to_owned(), + }, + ) + .unwrap(); + let response = AdminSuccessResponse::new( + correlation_id(), + SensitivePayload { + credential: "secret response credential".to_owned(), + }, + ) + .unwrap(); + + let request_debug = format!("{request:?}"); + let response_debug = format!("{response:?}"); + assert!(request_debug.contains("<redacted>")); + assert!(response_debug.contains("<redacted>")); + assert!(!request_debug.contains("secret request")); + assert!(!response_debug.contains("secret response")); + } + + #[derive(Debug)] + struct SensitiveCause; + + impl fmt::Display for SensitiveCause { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("database password and raw SQL") + } + } + + impl Error for SensitiveCause {} + + #[test] + fn host_error_mapping_preserves_only_the_safe_projection() { + let internal = HostError::with_source(HostErrorKind::TaskFailure, SensitiveCause); + let response = + AdminFailureResponse::new(correlation_id(), AdminError::from_host_error(&internal)); + let encoded = serde_json::to_string(&response).unwrap(); + + assert_eq!( + encoded, + r#"{"contract_version":1,"ok":false,"correlation_id":"safe-correlation","error":{"code":"host_task_failure","message":"authoritative service task failed"}}"# + ); + assert!(!encoded.contains("password")); + assert!(!encoded.contains("SQL")); + } +} diff --git a/crates/service_host/src/lib.rs b/crates/service_host/src/lib.rs @@ -2,6 +2,7 @@ //! Reusable, service-neutral host mechanics for Radroots services. +pub mod admin; pub mod build_info; pub mod entropy; pub mod error; @@ -9,6 +10,14 @@ pub mod lifecycle; pub mod status; pub mod time; +pub use admin::{ + ADMIN_CONTRACT_VERSION, ADMIN_CORRELATION_ID_MAX_UTF8_BYTES, ADMIN_ERROR_CODE_MAX_UTF8_BYTES, + ADMIN_ERROR_MESSAGE_MAX_UTF8_BYTES, ADMIN_OPERATION_ID_MAX_UTF8_BYTES, + AdminContractVersionError, AdminCorrelationId, AdminError, AdminErrorCode, AdminErrorCodeError, + AdminErrorMessage, AdminErrorMessageError, AdminFailureResponse, AdminIdentifierError, + AdminIdentifierField, AdminMutationRequest, AdminOperationId, AdminPayloadError, + AdminSuccessResponse, +}; pub use build_info::{ BuildInfo, BuildInfoEnvironment, BuildInfoError, BuildInfoField, BuildMode, ContractVersions, StatusBuildInfo, diff --git a/crates/service_host/tests/package_boundary.rs b/crates/service_host/tests/package_boundary.rs @@ -2,6 +2,10 @@ use std::collections::BTreeSet; const MANIFEST: &str = include_str!("../Cargo.toml"); const ROOT: &str = include_str!("../src/lib.rs"); +const ADMIN_SOURCE: &str = concat!( + include_str!("../src/admin/mod.rs"), + include_str!("../src/admin/model.rs"), +); const STATUS_SOURCE: &str = concat!( include_str!("../src/status/mod.rs"), include_str!("../src/status/phase.rs"), @@ -45,6 +49,7 @@ fn service_host_is_unpublished_lint_governed_and_dependency_bounded() { assert_eq!( public_modules(ROOT), BTreeSet::from([ + "admin", "build_info", "entropy", "error", @@ -54,6 +59,7 @@ fn service_host_is_unpublished_lint_governed_and_dependency_bounded() { ]) ); assert!(!STATUS_SOURCE.contains("serde(untagged)")); + assert!(!ADMIN_SOURCE.contains("serde(untagged)")); for forbidden in ["tokio::signal", "ctrl_c", "signal_hook"] { assert!(!LIFECYCLE_SOURCE.contains(forbidden)); }