lib

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

commit 92b0585b4e1e5dd6dd536e49a4a6efee69de20d7
parent b30761b8fdf8c04d8e4d4de3e5db12d26fdbd600
Author: triesap <tyson@radroots.org>
Date:   Sat, 15 Aug 2026 21:51:16 +0000

service-host: bound status and admin ingestion

Diffstat:
Mcrates/service_host/README.md | 6++++++
Mcrates/service_host/src/admin/client.rs | 135++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mcrates/service_host/src/admin/model.rs | 498++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/service_host/src/admin/server.rs | 110+++++++++++++++++++++++++++++++++++++------------------------------------------
Mcrates/service_host/src/status/reason.rs | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/service_host/tests/package_boundary.rs | 21+++++++++++++++++++++
6 files changed, 738 insertions(+), 143 deletions(-)

diff --git a/crates/service_host/README.md b/crates/service_host/README.md @@ -95,6 +95,12 @@ exclude raw paths, SQL, credentials, payloads, and provider or relay errors. Where a host error retains an original cause for trusted inspection, that cause is not rendered by its safe projection. +Reason-code iterators and wire arrays stop at the first item beyond their fixed +maximum. Administration payloads are traversed directly from the bounded input +or streamed directly into the capped response writer; validation does not +materialize an intermediate `serde_json::Value` tree. Recursive null rejection +and duplicate or unknown field rejection remain fail closed. + ## Process and runtime ownership The crate does not parse a CLI, read configuration from environment variables, diff --git a/crates/service_host/src/admin/client.rs b/crates/service_host/src/admin/client.rs @@ -2,7 +2,7 @@ use core::fmt; use serde::{Deserialize, Deserializer, Serialize, de, de::DeserializeOwned}; -use serde_json::Value; +use std::collections::BTreeSet; use std::error::Error; use std::io; use std::path::{Path, PathBuf}; @@ -300,7 +300,7 @@ impl AdminClient { AdminClientError::sourced(AdminClientErrorKind::RequestEncoding, error) } })?; - let _: StrictJsonValue = serde_json::from_slice(&body).map_err(|error| { + let _: StrictJsonPayload = serde_json::from_slice(&body).map_err(|error| { AdminClientError::sourced(AdminClientErrorKind::RequestEncoding, error) })?; self.execute(AdminHttpMethod::Post, target, Bytes::from(body)) @@ -521,16 +521,10 @@ fn decode_response<T>( where T: DeserializeOwned + Serialize, { - let strict = serde_json::from_slice::<StrictJsonValue>(body).map_err(|error| { + let strict = serde_json::from_slice::<StrictResponseEnvelope>(body).map_err(|error| { AdminClientError::sourced(AdminClientErrorKind::MalformedResponse, error) })?; - let version = strict - .0 - .as_object() - .and_then(|object| object.get("contract_version")) - .and_then(Value::as_u64) - .ok_or_else(|| AdminClientError::simple(AdminClientErrorKind::MalformedResponse))?; - if version != u64::from(ADMIN_CONTRACT_VERSION) { + if strict.contract_version != u64::from(ADMIN_CONTRACT_VERSION) { return Err(AdminClientError::simple( AdminClientErrorKind::UnsupportedContractVersion, )); @@ -647,9 +641,10 @@ where } } -struct StrictJsonValue(Value); +#[derive(Clone, Copy)] +struct StrictJsonPayload; -impl<'de> Deserialize<'de> for StrictJsonValue { +impl<'de> Deserialize<'de> for StrictJsonPayload { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: Deserializer<'de>, @@ -661,36 +656,37 @@ impl<'de> Deserialize<'de> for StrictJsonValue { struct StrictJsonVisitor; impl<'de> de::Visitor<'de> for StrictJsonVisitor { - type Value = StrictJsonValue; + type Value = StrictJsonPayload; fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("JSON without duplicate object keys or null values") } - fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> { - Ok(StrictJsonValue(Value::Bool(value))) + fn visit_bool<E>(self, _value: bool) -> Result<Self::Value, E> { + Ok(StrictJsonPayload) } - fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> { - Ok(StrictJsonValue(Value::Number(value.into()))) + fn visit_i64<E>(self, _value: i64) -> Result<Self::Value, E> { + Ok(StrictJsonPayload) } - fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> { - Ok(StrictJsonValue(Value::Number(value.into()))) + fn visit_u64<E>(self, _value: u64) -> Result<Self::Value, E> { + Ok(StrictJsonPayload) } fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E> where E: de::Error, { - serde_json::Number::from_f64(value) - .map(Value::Number) - .map(StrictJsonValue) - .ok_or_else(|| E::custom("non-finite JSON number")) + if value.is_finite() { + Ok(StrictJsonPayload) + } else { + Err(E::custom("non-finite JSON number")) + } } - fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> { - Ok(StrictJsonValue(Value::String(value.to_owned()))) + fn visit_str<E>(self, _value: &str) -> Result<Self::Value, E> { + Ok(StrictJsonPayload) } fn visit_borrowed_str<E>(self, value: &'de str) -> Result<Self::Value, E> @@ -700,8 +696,8 @@ impl<'de> de::Visitor<'de> for StrictJsonVisitor { self.visit_str(value) } - fn visit_string<E>(self, value: String) -> Result<Self::Value, E> { - Ok(StrictJsonValue(Value::String(value))) + fn visit_string<E>(self, _value: String) -> Result<Self::Value, E> { + Ok(StrictJsonPayload) } fn visit_none<E>(self) -> Result<Self::Value, E> @@ -722,26 +718,67 @@ impl<'de> de::Visitor<'de> for StrictJsonVisitor { where A: de::SeqAccess<'de>, { - let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0)); - while let Some(value) = sequence.next_element::<StrictJsonValue>()? { - values.push(value.0); + while sequence.next_element::<StrictJsonPayload>()?.is_some() {} + Ok(StrictJsonPayload) + } + + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: de::MapAccess<'de>, + { + let mut keys = BTreeSet::new(); + while let Some(key) = map.next_key::<String>()? { + if !keys.insert(key) { + return Err(de::Error::custom("duplicate JSON object key")); + } + map.next_value::<StrictJsonPayload>()?; } - Ok(StrictJsonValue(Value::Array(values))) + Ok(StrictJsonPayload) + } +} + +struct StrictResponseEnvelope { + contract_version: u64, +} + +impl<'de> Deserialize<'de> for StrictResponseEnvelope { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + deserializer.deserialize_map(StrictResponseVisitor) + } +} + +struct StrictResponseVisitor; + +impl<'de> de::Visitor<'de> for StrictResponseVisitor { + type Value = StrictResponseEnvelope; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an admin response envelope without duplicate keys or null values") } fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> where A: de::MapAccess<'de>, { - let mut values = serde_json::Map::with_capacity(map.size_hint().unwrap_or(0)); + let mut keys = BTreeSet::new(); + let mut contract_version = None; while let Some(key) = map.next_key::<String>()? { - if values.contains_key(&key) { + if !keys.insert(key.clone()) { return Err(de::Error::custom("duplicate JSON object key")); } - let value = map.next_value::<StrictJsonValue>()?; - values.insert(key, value.0); + if key == "contract_version" { + contract_version = Some(map.next_value::<u64>()?); + } else { + map.next_value::<StrictJsonPayload>()?; + } } - Ok(StrictJsonValue(Value::Object(values))) + Ok(StrictResponseEnvelope { + contract_version: contract_version + .ok_or_else(|| de::Error::missing_field("contract_version"))?, + }) } } @@ -1179,25 +1216,19 @@ mod tests { #[test] fn strict_response_target_and_error_helpers_cover_the_full_value_surface() { - for (document, expected) in [ - ("true", Value::Bool(true)), - ("-3", Value::Number((-3).into())), - ("4", Value::Number(4_u64.into())), - ( - "2.5", - Value::Number(serde_json::Number::from_f64(2.5).unwrap()), - ), - (r#""text""#, Value::String("text".to_owned())), - ("[true,2]", serde_json::json!([true, 2])), - (r#"{"value":3}"#, serde_json::json!({"value": 3})), + for document in [ + "true", + "-3", + "4", + "2.5", + r#""text""#, + "[true,2]", + r#"{"value":3}"#, ] { - assert_eq!( - serde_json::from_str::<StrictJsonValue>(document).unwrap().0, - expected - ); + serde_json::from_str::<StrictJsonPayload>(document).unwrap(); } for rejected in ["null", "[1,null]", r#"{"same":1,"same":2}"#] { - assert!(serde_json::from_str::<StrictJsonValue>(rejected).is_err()); + assert!(serde_json::from_str::<StrictJsonPayload>(rejected).is_err()); } let target = AdminClientTarget::new("/v1/items/value%2D1?page=1&limit=2").unwrap(); diff --git a/crates/service_host/src/admin/model.rs b/crates/service_host/src/admin/model.rs @@ -208,7 +208,7 @@ where T: Serialize, { fn new(value: T) -> Result<Self, AdminPayloadError> { - checked_json_value(&value)?; + checked_non_null(&value)?; Ok(Self(value)) } } @@ -221,9 +221,8 @@ where where S: Serializer, { - checked_json_value(&self.0) - .map_err(ser::Error::custom)? - .serialize(serializer) + checked_non_null(&self.0).map_err(ser::Error::custom)?; + self.0.serialize(serializer) } } @@ -846,23 +845,444 @@ where } } -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) +#[derive(Clone, Copy, Debug)] +enum PayloadValidationError { + Null, + Encoding, +} + +impl fmt::Display for PayloadValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("admin payload validation failed") + } +} + +impl Error for PayloadValidationError {} + +impl ser::Error for PayloadValidationError { + fn custom<T>(_message: T) -> Self + where + T: fmt::Display, + { + Self::Encoding + } +} + +#[derive(Clone, Copy)] +struct NonNullSerializer<'failure> { + failure: &'failure core::cell::Cell<Option<PayloadValidationError>>, +} + +impl NonNullSerializer<'_> { + fn finish(self) -> Result<(), PayloadValidationError> { + self.failure.get().map_or(Ok(()), Err) + } + + fn reject(self, error: PayloadValidationError) -> Result<(), PayloadValidationError> { + if self.failure.get().is_none() { + self.failure.set(Some(error)); + } + Err(error) + } +} + +impl<'failure> ser::Serializer for NonNullSerializer<'failure> { + type Ok = (); + type Error = PayloadValidationError; + type SerializeSeq = NonNullCompound<'failure>; + type SerializeTuple = NonNullCompound<'failure>; + type SerializeTupleStruct = NonNullCompound<'failure>; + type SerializeTupleVariant = NonNullCompound<'failure>; + type SerializeMap = NonNullCompound<'failure>; + type SerializeStruct = NonNullCompound<'failure>; + type SerializeStructVariant = NonNullCompound<'failure>; + + fn serialize_bool(self, _value: bool) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_i8(self, _value: i8) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_i16(self, _value: i16) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_i32(self, _value: i32) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_i64(self, _value: i64) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_i128(self, _value: i128) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_u8(self, _value: u8) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_u16(self, _value: u16) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_u32(self, _value: u32) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_u64(self, _value: u64) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_u128(self, _value: u128) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_f32(self, _value: f32) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_f64(self, _value: f64) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_char(self, _value: char) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_str(self, _value: &str) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_bytes(self, _value: &[u8]) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_none(self) -> Result<Self::Ok, Self::Error> { + self.reject(PayloadValidationError::Null) + } + + fn serialize_some<T>(self, value: &T) -> Result<Self::Ok, Self::Error> + where + T: ?Sized + Serialize, + { + value.serialize(self) + } + + fn serialize_unit(self) -> Result<Self::Ok, Self::Error> { + self.reject(PayloadValidationError::Null) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result<Self::Ok, Self::Error> { + self.reject(PayloadValidationError::Null) + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + ) -> Result<Self::Ok, Self::Error> { + self.finish() + } + + fn serialize_newtype_struct<T>( + self, + _name: &'static str, + value: &T, + ) -> Result<Self::Ok, Self::Error> + where + T: ?Sized + Serialize, + { + value.serialize(self) + } + + fn serialize_newtype_variant<T>( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + value: &T, + ) -> Result<Self::Ok, Self::Error> + where + T: ?Sized + Serialize, + { + value.serialize(self) + } + + fn serialize_seq(self, _len: Option<usize>) -> Result<Self::SerializeSeq, Self::Error> { + self.finish()?; + Ok(NonNullCompound(self)) + } + + fn serialize_tuple(self, _len: usize) -> Result<Self::SerializeTuple, Self::Error> { + self.finish()?; + Ok(NonNullCompound(self)) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result<Self::SerializeTupleStruct, Self::Error> { + self.finish()?; + Ok(NonNullCompound(self)) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result<Self::SerializeTupleVariant, Self::Error> { + self.finish()?; + Ok(NonNullCompound(self)) + } + + fn serialize_map(self, _len: Option<usize>) -> Result<Self::SerializeMap, Self::Error> { + self.finish()?; + Ok(NonNullCompound(self)) + } + + fn serialize_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result<Self::SerializeStruct, Self::Error> { + self.finish()?; + Ok(NonNullCompound(self)) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result<Self::SerializeStructVariant, Self::Error> { + self.finish()?; + Ok(NonNullCompound(self)) + } + + fn collect_str<T>(self, _value: &T) -> Result<Self::Ok, Self::Error> + where + T: ?Sized + fmt::Display, + { + self.finish() + } + + fn is_human_readable(&self) -> bool { + true + } +} + +struct NonNullCompound<'failure>(NonNullSerializer<'failure>); + +impl NonNullCompound<'_> { + fn value<T>(&mut self, value: &T) -> Result<(), PayloadValidationError> + where + T: ?Sized + Serialize, + { + value.serialize(self.0) + } + + fn finish(self) -> Result<(), PayloadValidationError> { + self.0.finish() + } +} + +impl ser::SerializeSeq for NonNullCompound<'_> { + type Ok = (); + type Error = PayloadValidationError; + + fn serialize_element<T>(&mut self, value: &T) -> Result<(), Self::Error> + where + T: ?Sized + Serialize, + { + self.value(value) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + self.finish() + } +} + +impl ser::SerializeTuple for NonNullCompound<'_> { + type Ok = (); + type Error = PayloadValidationError; + + fn serialize_element<T>(&mut self, value: &T) -> Result<(), Self::Error> + where + T: ?Sized + Serialize, + { + self.value(value) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + self.finish() + } +} + +impl ser::SerializeTupleStruct for NonNullCompound<'_> { + type Ok = (); + type Error = PayloadValidationError; + + fn serialize_field<T>(&mut self, value: &T) -> Result<(), Self::Error> + where + T: ?Sized + Serialize, + { + self.value(value) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + self.finish() + } +} + +impl ser::SerializeTupleVariant for NonNullCompound<'_> { + type Ok = (); + type Error = PayloadValidationError; + + fn serialize_field<T>(&mut self, value: &T) -> Result<(), Self::Error> + where + T: ?Sized + Serialize, + { + self.value(value) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + self.finish() + } +} + +impl ser::SerializeMap for NonNullCompound<'_> { + type Ok = (); + type Error = PayloadValidationError; + + fn serialize_key<T>(&mut self, key: &T) -> Result<(), Self::Error> + where + T: ?Sized + Serialize, + { + self.value(key) + } + + fn serialize_value<T>(&mut self, value: &T) -> Result<(), Self::Error> + where + T: ?Sized + Serialize, + { + self.value(value) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + self.finish() + } +} + +impl ser::SerializeStruct for NonNullCompound<'_> { + type Ok = (); + type Error = PayloadValidationError; + + fn serialize_field<T>(&mut self, _key: &'static str, value: &T) -> Result<(), Self::Error> + where + T: ?Sized + Serialize, + { + self.value(value) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + self.finish() } } -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, +impl ser::SerializeStructVariant for NonNullCompound<'_> { + type Ok = (); + type Error = PayloadValidationError; + + fn serialize_field<T>(&mut self, _key: &'static str, value: &T) -> Result<(), Self::Error> + where + T: ?Sized + Serialize, + { + self.value(value) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + self.finish() + } +} + +#[derive(Default)] +struct NonNullJsonWriter { + inside_string: bool, + escaped: bool, + null_progress: usize, + rejected: bool, +} + +impl std::io::Write for NonNullJsonWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> { + if self.rejected { + return Err(std::io::Error::other("JSON null is forbidden")); + } + for &byte in bytes { + if self.inside_string { + if self.escaped { + self.escaped = false; + } else if byte == b'\\' { + self.escaped = true; + } else if byte == b'"' { + self.inside_string = false; + } + continue; + } + + if byte == b'"' { + self.inside_string = true; + self.null_progress = 0; + continue; + } + + let expected = b"null"; + if byte == expected[self.null_progress] { + self.null_progress += 1; + if self.null_progress == expected.len() { + self.rejected = true; + return Err(std::io::Error::other("JSON null is forbidden")); + } + } else { + self.null_progress = usize::from(byte == b'n'); + } + } + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + if self.rejected { + Err(std::io::Error::other("JSON null is forbidden")) + } else { + Ok(()) + } + } +} + +fn checked_non_null(value: &impl Serialize) -> Result<(), AdminPayloadError> { + let failure = core::cell::Cell::new(None); + let serializer = NonNullSerializer { failure: &failure }; + let result = value.serialize(serializer); + match failure.get().or_else(|| result.err()) { + None => { + let mut writer = NonNullJsonWriter::default(); + match serde_json::to_writer(&mut writer, value) { + Ok(()) => Ok(()), + Err(_) if writer.rejected => Err(AdminPayloadError::NullForbidden), + Err(_) => Err(AdminPayloadError::Encoding), + } + } + Some(PayloadValidationError::Null) => Err(AdminPayloadError::NullForbidden), + Some(PayloadValidationError::Encoding) => Err(AdminPayloadError::Encoding), } } @@ -1728,6 +2148,48 @@ mod tests { ); } + struct IgnoredNestedNull; + + impl Serialize for IgnoredNestedNull { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + let mut structure = serializer.serialize_struct("IgnoredNestedNull", 1)?; + let _ = ser::SerializeStruct::serialize_field( + &mut structure, + "ignored", + &Option::<u8>::None, + ); + ser::SerializeStruct::end(structure) + } + } + + #[test] + fn construction_rejects_a_nested_null_even_when_custom_serialization_ignores_the_error() { + assert!(matches!( + AdminSuccessResponse::new(correlation_id(), IgnoredNestedNull), + Err(AdminPayloadError::NullForbidden) + )); + + let raw = serde_json::value::RawValue::from_string("null".to_owned()).unwrap(); + assert!(matches!( + AdminSuccessResponse::new(correlation_id(), raw), + Err(AdminPayloadError::NullForbidden) + )); + + let raw = serde_json::value::RawValue::from_string( + r#"{"null":"escaped \"null\" text"}"#.to_owned(), + ) + .unwrap(); + assert!(AdminSuccessResponse::new(correlation_id(), raw).is_ok()); + + let mut writer = NonNullJsonWriter::default(); + assert_eq!(std::io::Write::write(&mut writer, b"nu").unwrap(), 2); + assert!(std::io::Write::write(&mut writer, b"ll").is_err()); + assert!(std::io::Write::write(&mut writer, b"true").is_err()); + } + #[test] fn ordinary_debug_redacts_request_and_result_payloads() { let request = AdminMutationRequest::new( diff --git a/crates/service_host/src/admin/server.rs b/crates/service_host/src/admin/server.rs @@ -2,7 +2,7 @@ use core::fmt; use serde::{Deserialize, Deserializer, Serialize, de, de::DeserializeOwned}; -use serde_json::{Value, value::RawValue}; +use serde_json::value::RawValue; use std::collections::{BTreeMap, BTreeSet}; use std::convert::Infallible; use std::error::Error; @@ -315,7 +315,7 @@ impl AdminRequest { BoundedEncodingError::Limit => AdminRouteOutcomeError::ResponseLimit, BoundedEncodingError::Encoding => AdminRouteOutcomeError::Encoding, })?; - let _: StrictJsonValue = + let _: StrictJsonPayload = serde_json::from_slice(&encoded).map_err(|_| AdminRouteOutcomeError::InvalidPayload)?; let encoded = String::from_utf8(encoded).expect("serde_json output must always be valid UTF-8"); @@ -440,18 +440,19 @@ struct ServerSuccessEnvelope<'a> { result: &'a RawValue, } -struct StrictJsonValue(Value); +#[derive(Clone, Copy)] +struct StrictJsonPayload; -impl Serialize for StrictJsonValue { +impl Serialize for StrictJsonPayload { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: serde::Serializer, { - self.0.serialize(serializer) + serializer.serialize_bool(true) } } -impl<'de> Deserialize<'de> for StrictJsonValue { +impl<'de> Deserialize<'de> for StrictJsonPayload { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: Deserializer<'de>, @@ -463,36 +464,37 @@ impl<'de> Deserialize<'de> for StrictJsonValue { struct StrictJsonVisitor; impl<'de> de::Visitor<'de> for StrictJsonVisitor { - type Value = StrictJsonValue; + type Value = StrictJsonPayload; fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("JSON without duplicate object keys or null values") } - fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> { - Ok(StrictJsonValue(Value::Bool(value))) + fn visit_bool<E>(self, _value: bool) -> Result<Self::Value, E> { + Ok(StrictJsonPayload) } - fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> { - Ok(StrictJsonValue(Value::Number(value.into()))) + fn visit_i64<E>(self, _value: i64) -> Result<Self::Value, E> { + Ok(StrictJsonPayload) } - fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> { - Ok(StrictJsonValue(Value::Number(value.into()))) + fn visit_u64<E>(self, _value: u64) -> Result<Self::Value, E> { + Ok(StrictJsonPayload) } fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E> where E: de::Error, { - serde_json::Number::from_f64(value) - .map(Value::Number) - .map(StrictJsonValue) - .ok_or_else(|| E::custom("non-finite JSON number")) + if value.is_finite() { + Ok(StrictJsonPayload) + } else { + Err(E::custom("non-finite JSON number")) + } } - fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> { - Ok(StrictJsonValue(Value::String(value.to_owned()))) + fn visit_str<E>(self, _value: &str) -> Result<Self::Value, E> { + Ok(StrictJsonPayload) } fn visit_borrowed_str<E>(self, value: &'de str) -> Result<Self::Value, E> @@ -502,8 +504,8 @@ impl<'de> de::Visitor<'de> for StrictJsonVisitor { self.visit_str(value) } - fn visit_string<E>(self, value: String) -> Result<Self::Value, E> { - Ok(StrictJsonValue(Value::String(value))) + fn visit_string<E>(self, _value: String) -> Result<Self::Value, E> { + Ok(StrictJsonPayload) } fn visit_none<E>(self) -> Result<Self::Value, E> @@ -524,26 +526,22 @@ impl<'de> de::Visitor<'de> for StrictJsonVisitor { where A: de::SeqAccess<'de>, { - let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0)); - while let Some(value) = sequence.next_element::<StrictJsonValue>()? { - values.push(value.0); - } - Ok(StrictJsonValue(Value::Array(values))) + while sequence.next_element::<StrictJsonPayload>()?.is_some() {} + Ok(StrictJsonPayload) } fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> where A: de::MapAccess<'de>, { - let mut values = serde_json::Map::with_capacity(map.size_hint().unwrap_or(0)); + let mut keys = BTreeSet::new(); while let Some(key) = map.next_key::<String>()? { - if values.contains_key(&key) { + if !keys.insert(key) { return Err(de::Error::custom("duplicate JSON object key")); } - let value = map.next_value::<StrictJsonValue>()?; - values.insert(key, value.0); + map.next_value::<StrictJsonPayload>()?; } - Ok(StrictJsonValue(Value::Object(values))) + Ok(StrictJsonPayload) } } @@ -984,18 +982,18 @@ async fn process_request( } if method == AdminHttpMethod::Post { - let envelope = match serde_json::from_slice::<AdminMutationRequest<StrictJsonValue>>(&body) - { - Ok(envelope) => envelope, - Err(_) => { - return failure_response( - StatusCode::BAD_REQUEST, - correlation.current(), - known_error("malformed_json", "admin request body is not valid JSON"), - state.limits, - ); - } - }; + let envelope = + match serde_json::from_slice::<AdminMutationRequest<StrictJsonPayload>>(&body) { + Ok(envelope) => envelope, + Err(_) => { + return failure_response( + StatusCode::BAD_REQUEST, + correlation.current(), + known_error("malformed_json", "admin request body is not valid JSON"), + state.limits, + ); + } + }; if let Some(caller_correlation) = envelope.correlation_id().cloned() { correlation.replace(caller_correlation); } else if let Some(error) = deferred_entropy_failure { @@ -2094,25 +2092,19 @@ mod tests { headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/plain")); assert!(!is_json_content_type(&headers)); - for (document, expected) in [ - ("true", Value::Bool(true)), - ("-7", Value::Number((-7).into())), - ("9", Value::Number(9_u64.into())), - ( - "1.5", - Value::Number(serde_json::Number::from_f64(1.5).unwrap()), - ), - (r#""text""#, Value::String("text".to_owned())), - ("[true,2]", serde_json::json!([true, 2])), - (r#"{"value":3}"#, serde_json::json!({"value": 3})), + for document in [ + "true", + "-7", + "9", + "1.5", + r#""text""#, + "[true,2]", + r#"{"value":3}"#, ] { - assert_eq!( - serde_json::from_str::<StrictJsonValue>(document).unwrap().0, - expected - ); + serde_json::from_str::<StrictJsonPayload>(document).unwrap(); } for rejected in ["null", "[1,null]", r#"{"same":1,"same":2}"#] { - assert!(serde_json::from_str::<StrictJsonValue>(rejected).is_err()); + assert!(serde_json::from_str::<StrictJsonPayload>(rejected).is_err()); } let route = AdminRoutePath::new("/v1/items/{item_id}").unwrap(); diff --git a/crates/service_host/src/status/reason.rs b/crates/service_host/src/status/reason.rs @@ -115,15 +115,18 @@ impl ReasonCodes { } pub fn new(values: impl IntoIterator<Item = ReasonCode>) -> Result<Self, StatusContractError> { - let mut values: Vec<_> = values.into_iter().collect(); - values.sort_unstable(); - values.dedup(); - if values.len() > REASON_CODES_MAX_ITEMS { - return Err(StatusContractError::TooManyReasonCodes { - maximum: REASON_CODES_MAX_ITEMS, - }); + let mut bounded = Vec::with_capacity(REASON_CODES_MAX_ITEMS); + for value in values.into_iter().take(REASON_CODES_MAX_ITEMS + 1) { + if bounded.len() == REASON_CODES_MAX_ITEMS { + return Err(StatusContractError::TooManyReasonCodes { + maximum: REASON_CODES_MAX_ITEMS, + }); + } + bounded.push(value); } - Ok(Self(values)) + bounded.sort_unstable(); + bounded.dedup(); + Ok(Self(bounded)) } #[must_use] @@ -142,13 +145,43 @@ impl<'de> Deserialize<'de> for ReasonCodes { where D: Deserializer<'de>, { - let raw = Vec::<ReasonCode>::deserialize(deserializer)?; - if raw.windows(2).any(|pair| pair[0] >= pair[1]) { - return Err(serde::de::Error::custom( - "reason codes must be unique and canonically sorted", - )); + struct ReasonCodesVisitor; + + impl<'de> serde::de::Visitor<'de> for ReasonCodesVisitor { + type Value = ReasonCodes; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a bounded canonically sorted reason-code array") + } + + fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error> + where + A: serde::de::SeqAccess<'de>, + { + let mut values = Vec::with_capacity( + sequence + .size_hint() + .unwrap_or(0) + .min(REASON_CODES_MAX_ITEMS), + ); + while let Some(value) = sequence.next_element::<ReasonCode>()? { + if values.len() == REASON_CODES_MAX_ITEMS { + return Err(serde::de::Error::custom( + "reason-code collection exceeds its item limit", + )); + } + if values.last().is_some_and(|previous| previous >= &value) { + return Err(serde::de::Error::custom( + "reason codes must be unique and canonically sorted", + )); + } + values.push(value); + } + Ok(ReasonCodes(values)) + } } - Self::new(raw).map_err(serde::de::Error::custom) + + deserializer.deserialize_seq(ReasonCodesVisitor) } } @@ -227,4 +260,54 @@ mod tests { assert!(serde_json::from_str::<ReasonCodes>(r#"["z_reason","a_reason"]"#).is_err()); assert!(serde_json::from_str::<ReasonCodes>(r#"["a_reason","a_reason"]"#).is_err()); } + + #[test] + fn collection_ingestion_stops_at_maximum_plus_one() { + use core::cell::Cell; + + struct CountedInfinite<'a> { + calls: &'a Cell<usize>, + value: ReasonCode, + } + + impl Iterator for CountedInfinite<'_> { + type Item = ReasonCode; + + fn next(&mut self) -> Option<Self::Item> { + self.calls.set(self.calls.get() + 1); + Some(self.value.clone()) + } + } + + let calls = Cell::new(0); + assert_eq!( + ReasonCodes::new(CountedInfinite { + calls: &calls, + value: ReasonCode::new("same_reason").unwrap(), + }), + Err(StatusContractError::TooManyReasonCodes { + maximum: REASON_CODES_MAX_ITEMS, + }) + ); + assert_eq!(calls.get(), REASON_CODES_MAX_ITEMS + 1); + + let maximum = (0..REASON_CODES_MAX_ITEMS) + .map(|index| format!("reason_{index:02}")) + .collect::<Vec<_>>(); + let maximum_json = serde_json::to_string(&maximum).unwrap(); + assert_eq!( + serde_json::from_str::<ReasonCodes>(&maximum_json) + .unwrap() + .as_slice() + .len(), + REASON_CODES_MAX_ITEMS + ); + let over_maximum = (0..=REASON_CODES_MAX_ITEMS) + .map(|index| format!("reason_{index:02}")) + .collect::<Vec<_>>(); + assert!( + serde_json::from_str::<ReasonCodes>(&serde_json::to_string(&over_maximum).unwrap()) + .is_err() + ); + } } diff --git a/crates/service_host/tests/package_boundary.rs b/crates/service_host/tests/package_boundary.rs @@ -91,6 +91,26 @@ fn service_host_is_unpublished_lint_governed_and_dependency_bounded() { assert!(ROOT.contains("pub use radroots_runtime_paths::{InstanceId, ServiceId};")); assert!(!STATUS_SOURCE.contains("serde(untagged)")); assert!(!ADMIN_SOURCE.contains("serde(untagged)")); + for required in [ + ".take(REASON_CODES_MAX_ITEMS + 1)", + "deserialize_seq(ReasonCodesVisitor)", + ] { + assert!(STATUS_SOURCE.contains(required)); + } + for required in [ + "struct NonNullSerializer", + "struct StrictJsonPayload", + "encode_bounded(result, self.response_body_limit)", + ] { + assert!(ADMIN_SOURCE.contains(required)); + } + for forbidden in [ + "serde_json::Value", + "serde_json::to_value", + "StrictJsonValue", + ] { + assert!(!ADMIN_SOURCE.contains(forbidden)); + } for forbidden in ["tokio::signal", "ctrl_c", "signal_hook"] { assert!(!LIFECYCLE_SOURCE.contains(forbidden)); } @@ -129,6 +149,7 @@ fn documentation_and_reviewed_public_api_are_complete_and_dependency_safe() { "BoundedCount::<64>::new(8)", "cached_service_state(CachedServiceState::new", "parent.child_token()", + "streamed directly into the capped response writer", ] { assert!(README.contains(required), "README is missing `{required}`"); }