lib

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

commit 808a3cebd2878b0562f061430589926252f5726c
parent 8dc7308cf5224cbc92db0ed52bd902f0514d0fcc
Author: triesap <tyson@radroots.org>
Date:   Tue, 14 Jul 2026 18:28:56 +0000

event: enforce domain event budgets

- classify NIP-01 replaceable event kinds 0, 3, and 10000 through 19999
- add domain envelope limits for content and tag resource budgets
- route tag and envelope constructors through budget validation
- validate with radroots_event tests and workspace cargo check

Diffstat:
Mcrates/event/src/envelope.rs | 258+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/event/src/lib.rs | 6+++---
2 files changed, 255 insertions(+), 9 deletions(-)

diff --git a/crates/event/src/envelope.rs b/crates/event/src/envelope.rs @@ -9,7 +9,10 @@ use std::{string::String, vec::Vec}; use crate::ids::{ RadrootsEventId, RadrootsEventSignature, RadrootsIdParseError, RadrootsPublicKey, }; -use crate::wire::RadrootsNip01EventWire; +use crate::wire::{ + DEFAULT_CONTENT_MAX_BYTES, DEFAULT_TAG_ELEMENT_MAX_BYTES, DEFAULT_TAG_MAX_COUNT, + DEFAULT_TAG_TOTAL_MAX_BYTES, RadrootsNip01EventWire, +}; use core::fmt; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -76,6 +79,7 @@ impl RadrootsEventKind { pub const fn class(self) -> RadrootsEventKindClass { match self.0 { + 0 | 3 => RadrootsEventKindClass::Replaceable, 10_000..=19_999 => RadrootsEventKindClass::Replaceable, 20_000..=29_999 => RadrootsEventKindClass::Ephemeral, 30_000..=39_999 => RadrootsEventKindClass::Addressable, @@ -135,9 +139,33 @@ pub enum RadrootsEventEnvelopeError { NonCanonicalId, NonCanonicalAuthor, NonCanonicalSignature, - EmptyTag { index: usize }, - EmptyTagKey { index: usize }, - ControlCharacterTagKey { index: usize }, + EmptyTag { + index: usize, + }, + EmptyTagKey { + index: usize, + }, + ControlCharacterTagKey { + index: usize, + }, + ContentTooLarge { + max: usize, + actual: usize, + }, + TooManyTags { + max: usize, + actual: usize, + }, + TagElementTooLarge { + tag_index: usize, + element_index: usize, + max: usize, + actual: usize, + }, + TagsTooLarge { + max: usize, + actual: usize, + }, } impl fmt::Display for RadrootsEventEnvelopeError { @@ -166,6 +194,27 @@ impl fmt::Display for RadrootsEventEnvelopeError { "event envelope tag {index} key contains a control character" ) } + Self::ContentTooLarge { max, actual } => { + write!( + f, + "event envelope content size {actual} exceeds {max} bytes" + ) + } + Self::TooManyTags { max, actual } => { + write!(f, "event envelope tag count {actual} exceeds {max}") + } + Self::TagElementTooLarge { + tag_index, + element_index, + max, + actual, + } => write!( + f, + "event envelope tag {tag_index} element {element_index} size {actual} exceeds {max} bytes" + ), + Self::TagsTooLarge { max, actual } => { + write!(f, "event envelope tag bytes {actual} exceed {max}") + } } } } @@ -173,12 +222,46 @@ impl fmt::Display for RadrootsEventEnvelopeError { #[cfg(feature = "std")] impl std::error::Error for RadrootsEventEnvelopeError {} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RadrootsEventEnvelopeLimits { + pub max_content_bytes: usize, + pub max_tag_count: usize, + pub max_tag_element_bytes: usize, + pub max_total_tag_bytes: usize, +} + +impl Default for RadrootsEventEnvelopeLimits { + fn default() -> Self { + Self { + max_content_bytes: DEFAULT_CONTENT_MAX_BYTES, + max_tag_count: DEFAULT_TAG_MAX_COUNT, + max_tag_element_bytes: DEFAULT_TAG_ELEMENT_MAX_BYTES, + max_total_tag_bytes: DEFAULT_TAG_TOTAL_MAX_BYTES, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsEventTag(Vec<String>); impl RadrootsEventTag { pub fn new(index: usize, values: Vec<String>) -> Result<Self, RadrootsEventEnvelopeError> { + Self::new_with_limits(index, values, RadrootsEventEnvelopeLimits::default()) + } + + pub fn new_with_limits( + index: usize, + values: Vec<String>, + limits: RadrootsEventEnvelopeLimits, + ) -> Result<Self, RadrootsEventEnvelopeError> { validate_tag(index, &values)?; + let total_bytes = validate_tag_elements(index, &values, limits)?; + if total_bytes > limits.max_total_tag_bytes { + return Err(RadrootsEventEnvelopeError::TagsTooLarge { + max: limits.max_total_tag_bytes, + actual: total_bytes, + }); + } Ok(Self(values)) } @@ -219,9 +302,33 @@ pub struct RadrootsEventTags(Vec<RadrootsEventTag>); impl RadrootsEventTags { pub fn new(values: Vec<Vec<String>>) -> Result<Self, RadrootsEventEnvelopeError> { + Self::new_with_limits(values, RadrootsEventEnvelopeLimits::default()) + } + + pub fn new_with_limits( + values: Vec<Vec<String>>, + limits: RadrootsEventEnvelopeLimits, + ) -> Result<Self, RadrootsEventEnvelopeError> { + let tag_count = values.len(); + if tag_count > limits.max_tag_count { + return Err(RadrootsEventEnvelopeError::TooManyTags { + max: limits.max_tag_count, + actual: tag_count, + }); + } let mut tags = Vec::with_capacity(values.len()); + let mut total_tag_bytes = 0usize; for (index, tag) in values.into_iter().enumerate() { - tags.push(RadrootsEventTag::new(index, tag)?); + validate_tag(index, &tag)?; + total_tag_bytes = + total_tag_bytes.saturating_add(validate_tag_elements(index, &tag, limits)?); + if total_tag_bytes > limits.max_total_tag_bytes { + return Err(RadrootsEventEnvelopeError::TagsTooLarge { + max: limits.max_total_tag_bytes, + actual: total_tag_bytes, + }); + } + tags.push(RadrootsEventTag(tag)); } Ok(Self(tags)) } @@ -311,6 +418,13 @@ pub struct RadrootsEventEnvelope { impl RadrootsEventEnvelope { pub fn new(parts: RadrootsEventEnvelopeParts) -> Result<Self, RadrootsEventEnvelopeError> { + Self::new_with_limits(parts, RadrootsEventEnvelopeLimits::default()) + } + + pub fn new_with_limits( + parts: RadrootsEventEnvelopeParts, + limits: RadrootsEventEnvelopeLimits, + ) -> Result<Self, RadrootsEventEnvelopeError> { let id = RadrootsEventId::parse(parts.id.as_str()) .map_err(RadrootsEventEnvelopeError::InvalidId)?; if id.as_str() != parts.id.as_str() { @@ -326,7 +440,14 @@ impl RadrootsEventEnvelope { if sig.as_str() != parts.sig.as_str() { return Err(RadrootsEventEnvelopeError::NonCanonicalSignature); } - let tags = RadrootsEventTags::new(parts.tags)?; + let content_len = parts.content.len(); + if content_len > limits.max_content_bytes { + return Err(RadrootsEventEnvelopeError::ContentTooLarge { + max: limits.max_content_bytes, + actual: content_len, + }); + } + let tags = RadrootsEventTags::new_with_limits(parts.tags, limits)?; Ok(Self { id, author, @@ -439,6 +560,27 @@ fn validate_tag(index: usize, values: &[String]) -> Result<(), RadrootsEventEnve Ok(()) } +fn validate_tag_elements( + tag_index: usize, + values: &[String], + limits: RadrootsEventEnvelopeLimits, +) -> Result<usize, RadrootsEventEnvelopeError> { + let mut total_tag_bytes = 0usize; + for (element_index, value) in values.iter().enumerate() { + let value_len = value.len(); + if value_len > limits.max_tag_element_bytes { + return Err(RadrootsEventEnvelopeError::TagElementTooLarge { + tag_index, + element_index, + max: limits.max_tag_element_bytes, + actual: value_len, + }); + } + total_tag_bytes = total_tag_bytes.saturating_add(value_len); + } + Ok(total_tag_bytes) +} + #[cfg(test)] mod tests { use super::*; @@ -500,28 +642,132 @@ mod tests { #[test] fn kind_classifies_nip01_ranges() { assert_eq!( + RadrootsEventKind::new(0).class(), + RadrootsEventKindClass::Replaceable + ); + assert_eq!( RadrootsEventKind::new(1).class(), RadrootsEventKindClass::Regular ); assert_eq!( + RadrootsEventKind::new(3).class(), + RadrootsEventKindClass::Replaceable + ); + assert_eq!( + RadrootsEventKind::new(9_999).class(), + RadrootsEventKindClass::Regular + ); + assert_eq!( RadrootsEventKind::new(10_000).class(), RadrootsEventKindClass::Replaceable ); assert_eq!( + RadrootsEventKind::new(19_999).class(), + RadrootsEventKindClass::Replaceable + ); + assert_eq!( RadrootsEventKind::new(20_000).class(), RadrootsEventKindClass::Ephemeral ); assert_eq!( + RadrootsEventKind::new(29_999).class(), + RadrootsEventKindClass::Ephemeral + ); + assert_eq!( RadrootsEventKind::new(30_000).class(), RadrootsEventKindClass::Addressable ); assert_eq!( + RadrootsEventKind::new(39_999).class(), + RadrootsEventKindClass::Addressable + ); + assert_eq!( RadrootsEventKind::new(40_000).class(), RadrootsEventKindClass::Regular ); } #[test] + fn envelope_rejects_domain_budget_violations() { + let mut parts = event_parts(); + assert_eq!( + RadrootsEventEnvelope::new_with_limits( + parts.clone(), + RadrootsEventEnvelopeLimits { + max_content_bytes: 4, + ..RadrootsEventEnvelopeLimits::default() + } + ), + Err(RadrootsEventEnvelopeError::ContentTooLarge { max: 4, actual: 5 }) + ); + + parts.tags = vec![vec!["d".to_owned()]]; + assert_eq!( + RadrootsEventEnvelope::new_with_limits( + parts.clone(), + RadrootsEventEnvelopeLimits { + max_tag_count: 0, + ..RadrootsEventEnvelopeLimits::default() + } + ), + Err(RadrootsEventEnvelopeError::TooManyTags { max: 0, actual: 1 }) + ); + + parts.tags = vec![vec!["d".to_owned(), "abcd".to_owned()]]; + assert_eq!( + RadrootsEventEnvelope::new_with_limits( + parts.clone(), + RadrootsEventEnvelopeLimits { + max_tag_element_bytes: 3, + ..RadrootsEventEnvelopeLimits::default() + } + ), + Err(RadrootsEventEnvelopeError::TagElementTooLarge { + tag_index: 0, + element_index: 1, + max: 3, + actual: 4 + }) + ); + + parts.tags = vec![vec!["d".to_owned(), "soil".to_owned()]]; + assert_eq!( + RadrootsEventEnvelope::new_with_limits( + parts, + RadrootsEventEnvelopeLimits { + max_total_tag_bytes: 4, + ..RadrootsEventEnvelopeLimits::default() + } + ), + Err(RadrootsEventEnvelopeError::TagsTooLarge { max: 4, actual: 5 }) + ); + } + + #[test] + fn envelope_accepts_exact_domain_budget_boundaries() { + let mut parts = event_parts(); + parts.content = "hello".to_owned(); + parts.tags = vec![vec!["d".to_owned(), "soil".to_owned()]]; + + let envelope = RadrootsEventEnvelope::new_with_limits( + parts, + RadrootsEventEnvelopeLimits { + max_content_bytes: 5, + max_tag_count: 1, + max_tag_element_bytes: 4, + max_total_tag_bytes: 5, + }, + ) + .expect("envelope"); + + assert_eq!(envelope.content(), "hello"); + assert_eq!( + envelope.tags_as_vec(), + vec![vec!["d".to_owned(), "soil".to_owned()]] + ); + } + + #[test] fn envelope_serializes_to_domain_shape() { let envelope = RadrootsEventEnvelope::new(event_parts()).expect("envelope"); let encoded = serde_json::to_value(&envelope).expect("json"); diff --git a/crates/event/src/lib.rs b/crates/event/src/lib.rs @@ -65,9 +65,9 @@ pub mod trade_validation; pub mod wire; pub use envelope::{ - RadrootsEventEnvelope, RadrootsEventEnvelopeError, RadrootsEventEnvelopeParts, - RadrootsEventKind, RadrootsEventKindClass, RadrootsEventTag, RadrootsEventTags, - RadrootsEventTimestamp, + RadrootsEventEnvelope, RadrootsEventEnvelopeError, RadrootsEventEnvelopeLimits, + RadrootsEventEnvelopeParts, RadrootsEventKind, RadrootsEventKindClass, RadrootsEventTag, + RadrootsEventTags, RadrootsEventTimestamp, }; pub use wire::{ RadrootsCanonicalEventIdError, RadrootsEventWireError, RadrootsEventWireLimits,