tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 4290aa202df35b74fc382a5cd855590a12e31b84
parent 3974d2497cb756060fe04a17c29d8d1abd24d729
Author: triesap <tyson@radroots.org>
Date:   Fri,  5 Jun 2026 21:29:32 -0700

core: add runtime limits

Diffstat:
MCargo.lock | 8++++++++
MCargo.toml | 1+
Acrates/tangle_core/Cargo.toml | 17+++++++++++++++++
Acrates/tangle_core/src/lib.rs | 720+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 746 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -374,6 +374,14 @@ name = "tangle" version = "0.1.0" [[package]] +name = "tangle_core" +version = "0.1.0" +dependencies = [ + "tangle_protocol", + "tangle_test_support", +] + +[[package]] name = "tangle_crypto" version = "0.1.0" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/tangle", + "crates/tangle_core", "crates/tangle_crypto", "crates/tangle_nips", "crates/tangle_protocol", diff --git a/crates/tangle_core/Cargo.toml b/crates/tangle_core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "tangle_core" +version.workspace = true +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Transport-independent relay core policy for tangle" + +[dependencies] +tangle_protocol = { path = "../tangle_protocol" } + +[dev-dependencies] +tangle_test_support = { path = "../tangle_test_support" } + +[lints] +workspace = true diff --git a/crates/tangle_core/src/lib.rs b/crates/tangle_core/src/lib.rs @@ -0,0 +1,720 @@ +#![forbid(unsafe_code)] + +use core::fmt; +use tangle_protocol::{Event, UnixTimestamp, event_to_value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RuntimeLimitValues { + pub max_event_bytes: u64, + pub max_content_bytes: u64, + pub max_tags_per_event: u64, + pub max_tag_values_per_tag: u64, + pub max_tag_value_bytes: u64, + pub max_filters_per_subscription: u64, + pub max_subscriptions_per_connection: u64, + pub max_search_query_bytes: u64, + pub max_search_tokens: u64, + pub max_filter_complexity: u64, + pub max_future_seconds: u64, + pub live_event_buffer: u64, + pub pending_store_events: u64, +} + +impl Default for RuntimeLimitValues { + fn default() -> Self { + Self { + max_event_bytes: 131_072, + max_content_bytes: 65_536, + max_tags_per_event: 128, + max_tag_values_per_tag: 16, + max_tag_value_bytes: 1_024, + max_filters_per_subscription: 16, + max_subscriptions_per_connection: 64, + max_search_query_bytes: 256, + max_search_tokens: 16, + max_filter_complexity: 512, + max_future_seconds: 900, + live_event_buffer: 1_024, + pending_store_events: 4_096, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RuntimeLimits { + values: RuntimeLimitValues, +} + +impl Default for RuntimeLimits { + fn default() -> Self { + Self::from_values(RuntimeLimitValues::default()).expect("default runtime limits are valid") + } +} + +impl RuntimeLimits { + pub fn from_values(values: RuntimeLimitValues) -> Result<Self, RuntimeLimitConfigError> { + require_positive("max_event_bytes", values.max_event_bytes)?; + require_positive("max_content_bytes", values.max_content_bytes)?; + require_positive("max_tags_per_event", values.max_tags_per_event)?; + require_positive("max_tag_values_per_tag", values.max_tag_values_per_tag)?; + require_positive("max_tag_value_bytes", values.max_tag_value_bytes)?; + require_positive( + "max_filters_per_subscription", + values.max_filters_per_subscription, + )?; + require_positive( + "max_subscriptions_per_connection", + values.max_subscriptions_per_connection, + )?; + require_positive("max_search_query_bytes", values.max_search_query_bytes)?; + require_positive("max_search_tokens", values.max_search_tokens)?; + require_positive("max_filter_complexity", values.max_filter_complexity)?; + require_positive("live_event_buffer", values.live_event_buffer)?; + require_positive("pending_store_events", values.pending_store_events)?; + if values.max_content_bytes > values.max_event_bytes { + return Err(RuntimeLimitConfigError::Inconsistent { + field: "max_content_bytes", + maximum_field: "max_event_bytes", + value: values.max_content_bytes, + maximum: values.max_event_bytes, + }); + } + Ok(Self { values }) + } + + pub fn values(self) -> RuntimeLimitValues { + self.values + } + + pub fn max_event_bytes(self) -> u64 { + self.values.max_event_bytes + } + + pub fn max_content_bytes(self) -> u64 { + self.values.max_content_bytes + } + + pub fn max_tags_per_event(self) -> u64 { + self.values.max_tags_per_event + } + + pub fn max_tag_values_per_tag(self) -> u64 { + self.values.max_tag_values_per_tag + } + + pub fn max_tag_value_bytes(self) -> u64 { + self.values.max_tag_value_bytes + } + + pub fn max_filters_per_subscription(self) -> u64 { + self.values.max_filters_per_subscription + } + + pub fn max_subscriptions_per_connection(self) -> u64 { + self.values.max_subscriptions_per_connection + } + + pub fn max_search_query_bytes(self) -> u64 { + self.values.max_search_query_bytes + } + + pub fn max_search_tokens(self) -> u64 { + self.values.max_search_tokens + } + + pub fn max_filter_complexity(self) -> u64 { + self.values.max_filter_complexity + } + + pub fn max_future_seconds(self) -> u64 { + self.values.max_future_seconds + } + + pub fn live_event_buffer(self) -> u64 { + self.values.live_event_buffer + } + + pub fn pending_store_events(self) -> u64 { + self.values.pending_store_events + } + + pub fn validate_event(&self, event: &Event) -> Result<(), RuntimeLimitViolation> { + let event_bytes = event_to_value(event).to_string().len() as u64; + require_within( + RuntimeLimitKind::EventBytes, + event_bytes, + self.values.max_event_bytes, + )?; + let content_bytes = event.unsigned().content().len() as u64; + require_within( + RuntimeLimitKind::ContentBytes, + content_bytes, + self.values.max_content_bytes, + )?; + let tag_count = event.unsigned().tags().len() as u64; + require_within( + RuntimeLimitKind::TagsPerEvent, + tag_count, + self.values.max_tags_per_event, + )?; + for tag in event.unsigned().tags() { + let value_count = tag.values().len() as u64; + require_within( + RuntimeLimitKind::TagValuesPerTag, + value_count, + self.values.max_tag_values_per_tag, + )?; + for value in tag.values() { + require_within( + RuntimeLimitKind::TagValueBytes, + value.len() as u64, + self.values.max_tag_value_bytes, + )?; + } + } + Ok(()) + } + + pub fn validate_filters( + &self, + filter_count: u64, + complexity: u64, + ) -> Result<(), RuntimeLimitViolation> { + require_within( + RuntimeLimitKind::FiltersPerSubscription, + filter_count, + self.values.max_filters_per_subscription, + )?; + require_within( + RuntimeLimitKind::FilterComplexity, + complexity, + self.values.max_filter_complexity, + ) + } + + pub fn validate_search_query(&self, query: &str) -> Result<(), RuntimeLimitViolation> { + require_within( + RuntimeLimitKind::SearchQueryBytes, + query.len() as u64, + self.values.max_search_query_bytes, + )?; + require_within( + RuntimeLimitKind::SearchTokens, + query.split_whitespace().count() as u64, + self.values.max_search_tokens, + ) + } + + pub fn validate_subscription_count( + &self, + active_subscriptions: u64, + ) -> Result<(), RuntimeLimitViolation> { + require_within( + RuntimeLimitKind::SubscriptionsPerConnection, + active_subscriptions, + self.values.max_subscriptions_per_connection, + ) + } + + pub fn validate_event_timestamp( + &self, + event: &Event, + now: UnixTimestamp, + ) -> Result<(), RuntimeLimitViolation> { + let created_at = event.unsigned().created_at().as_u64(); + let now = now.as_u64(); + if created_at <= now { + return Ok(()); + } + require_within( + RuntimeLimitKind::FutureSeconds, + created_at - now, + self.values.max_future_seconds, + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeLimitConfigError { + Zero { + field: &'static str, + }, + Inconsistent { + field: &'static str, + maximum_field: &'static str, + value: u64, + maximum: u64, + }, +} + +impl fmt::Display for RuntimeLimitConfigError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Zero { field } => write!(formatter, "`{field}` must be greater than zero"), + Self::Inconsistent { + field, + maximum_field, + value, + maximum, + } => write!( + formatter, + "`{field}` must not exceed `{maximum_field}` ({value} > {maximum})" + ), + } + } +} + +impl std::error::Error for RuntimeLimitConfigError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RuntimeLimitViolation { + kind: RuntimeLimitKind, + actual: u64, + maximum: u64, +} + +impl RuntimeLimitViolation { + pub fn new(kind: RuntimeLimitKind, actual: u64, maximum: u64) -> Self { + Self { + kind, + actual, + maximum, + } + } + + pub fn kind(self) -> RuntimeLimitKind { + self.kind + } + + pub fn actual(self) -> u64 { + self.actual + } + + pub fn maximum(self) -> u64 { + self.maximum + } +} + +impl fmt::Display for RuntimeLimitViolation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "{} exceeded: {} > {}", + self.kind.as_str(), + self.actual, + self.maximum + ) + } +} + +impl std::error::Error for RuntimeLimitViolation {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeLimitKind { + EventBytes, + ContentBytes, + TagsPerEvent, + TagValuesPerTag, + TagValueBytes, + FiltersPerSubscription, + SubscriptionsPerConnection, + SearchQueryBytes, + SearchTokens, + FilterComplexity, + FutureSeconds, +} + +impl RuntimeLimitKind { + pub fn as_str(self) -> &'static str { + match self { + Self::EventBytes => "event bytes", + Self::ContentBytes => "content bytes", + Self::TagsPerEvent => "tags per event", + Self::TagValuesPerTag => "tag values per tag", + Self::TagValueBytes => "tag value bytes", + Self::FiltersPerSubscription => "filters per subscription", + Self::SubscriptionsPerConnection => "subscriptions per connection", + Self::SearchQueryBytes => "search query bytes", + Self::SearchTokens => "search tokens", + Self::FilterComplexity => "filter complexity", + Self::FutureSeconds => "future seconds", + } + } +} + +impl fmt::Display for RuntimeLimitKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +fn require_positive(field: &'static str, value: u64) -> Result<(), RuntimeLimitConfigError> { + if value == 0 { + Err(RuntimeLimitConfigError::Zero { field }) + } else { + Ok(()) + } +} + +fn require_within( + kind: RuntimeLimitKind, + actual: u64, + maximum: u64, +) -> Result<(), RuntimeLimitViolation> { + if actual > maximum { + Err(RuntimeLimitViolation::new(kind, actual, maximum)) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{RuntimeLimitConfigError, RuntimeLimitKind, RuntimeLimitValues, RuntimeLimits}; + use tangle_protocol::{ + Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, + }; + use tangle_test_support::{build_fixture_event, valid_public_listing_spec}; + + #[test] + fn default_runtime_limits_expose_reference_aligned_boundaries() { + let limits = RuntimeLimits::default(); + let values = limits.values(); + + assert_eq!(limits.max_event_bytes(), 131_072); + assert_eq!(limits.max_content_bytes(), 65_536); + assert_eq!(limits.max_tags_per_event(), 128); + assert_eq!(limits.max_tag_values_per_tag(), 16); + assert_eq!(limits.max_tag_value_bytes(), 1_024); + assert_eq!(limits.max_filters_per_subscription(), 16); + assert_eq!(limits.max_subscriptions_per_connection(), 64); + assert_eq!(limits.max_search_query_bytes(), 256); + assert_eq!(limits.max_search_tokens(), 16); + assert_eq!(limits.max_filter_complexity(), 512); + assert_eq!(limits.max_future_seconds(), 900); + assert_eq!(limits.live_event_buffer(), 1_024); + assert_eq!(limits.pending_store_events(), 4_096); + assert_eq!(values.max_event_bytes, 131_072); + } + + #[test] + fn runtime_limit_config_rejects_zero_and_inconsistent_values() { + let zero_cases = [ + ( + "max_event_bytes", + RuntimeLimitValues { + max_event_bytes: 0, + ..RuntimeLimitValues::default() + }, + ), + ( + "max_content_bytes", + RuntimeLimitValues { + max_content_bytes: 0, + ..RuntimeLimitValues::default() + }, + ), + ( + "max_tags_per_event", + RuntimeLimitValues { + max_tags_per_event: 0, + ..RuntimeLimitValues::default() + }, + ), + ( + "max_tag_values_per_tag", + RuntimeLimitValues { + max_tag_values_per_tag: 0, + ..RuntimeLimitValues::default() + }, + ), + ( + "max_tag_value_bytes", + RuntimeLimitValues { + max_tag_value_bytes: 0, + ..RuntimeLimitValues::default() + }, + ), + ( + "max_filters_per_subscription", + RuntimeLimitValues { + max_filters_per_subscription: 0, + ..RuntimeLimitValues::default() + }, + ), + ( + "max_subscriptions_per_connection", + RuntimeLimitValues { + max_subscriptions_per_connection: 0, + ..RuntimeLimitValues::default() + }, + ), + ( + "max_search_query_bytes", + RuntimeLimitValues { + max_search_query_bytes: 0, + ..RuntimeLimitValues::default() + }, + ), + ( + "max_search_tokens", + RuntimeLimitValues { + max_search_tokens: 0, + ..RuntimeLimitValues::default() + }, + ), + ( + "max_filter_complexity", + RuntimeLimitValues { + max_filter_complexity: 0, + ..RuntimeLimitValues::default() + }, + ), + ( + "live_event_buffer", + RuntimeLimitValues { + live_event_buffer: 0, + ..RuntimeLimitValues::default() + }, + ), + ( + "pending_store_events", + RuntimeLimitValues { + pending_store_events: 0, + ..RuntimeLimitValues::default() + }, + ), + ]; + let inconsistent = RuntimeLimitValues { + max_event_bytes: 10, + max_content_bytes: 11, + ..RuntimeLimitValues::default() + }; + + for (field, values) in zero_cases { + assert_eq!( + RuntimeLimits::from_values(values).expect_err(field), + RuntimeLimitConfigError::Zero { field } + ); + } + assert_eq!( + RuntimeLimits::from_values(zero_cases[0].1) + .expect_err("zero") + .to_string(), + "`max_event_bytes` must be greater than zero" + ); + assert_eq!( + RuntimeLimits::from_values(inconsistent).expect_err("inconsistent"), + RuntimeLimitConfigError::Inconsistent { + field: "max_content_bytes", + maximum_field: "max_event_bytes", + value: 11, + maximum: 10, + } + ); + assert_eq!( + RuntimeLimits::from_values(inconsistent) + .expect_err("inconsistent") + .to_string(), + "`max_content_bytes` must not exceed `max_event_bytes` (11 > 10)" + ); + } + + #[test] + fn runtime_limits_accept_fixture_event_inside_boundaries() { + let event = build_fixture_event(&valid_public_listing_spec()).expect("event"); + + assert_eq!(RuntimeLimits::default().validate_event(&event), Ok(())); + assert_eq!( + RuntimeLimits::default() + .validate_event_timestamp(&event, UnixTimestamp::new(1_714_124_433)), + Ok(()) + ); + } + + #[test] + fn runtime_limits_reject_event_shape_boundaries() { + assert_eq!( + limits_with(|values| { + values.max_event_bytes = 10; + values.max_content_bytes = 10; + }) + .validate_event(&event_with(vec![], "small", UnixTimestamp::new(10))) + .expect_err("event bytes") + .kind(), + RuntimeLimitKind::EventBytes + ); + assert_eq!( + limits_with(|values| values.max_content_bytes = 3) + .validate_event(&event_with(vec![], "large", UnixTimestamp::new(10))) + .expect_err("content bytes") + .kind(), + RuntimeLimitKind::ContentBytes + ); + assert_eq!( + limits_with(|values| values.max_tags_per_event = 1) + .validate_event(&event_with( + vec![ + Tag::from_parts("d", &["one"]).expect("d"), + Tag::from_parts("t", &["two"]).expect("t"), + ], + "", + UnixTimestamp::new(10), + )) + .expect_err("tag count") + .kind(), + RuntimeLimitKind::TagsPerEvent + ); + assert_eq!( + limits_with(|values| values.max_tag_values_per_tag = 1) + .validate_event(&event_with( + vec![Tag::from_parts("t", &["one"]).expect("t")], + "", + UnixTimestamp::new(10), + )) + .expect_err("tag values") + .kind(), + RuntimeLimitKind::TagValuesPerTag + ); + assert_eq!( + limits_with(|values| values.max_tag_value_bytes = 1) + .validate_event(&event_with( + vec![Tag::from_parts("t", &["two"]).expect("t")], + "", + UnixTimestamp::new(10), + )) + .expect_err("tag value bytes") + .kind(), + RuntimeLimitKind::TagValueBytes + ); + } + + #[test] + fn runtime_limits_reject_filter_subscription_search_and_future_boundaries() { + let limits = limits_with(|values| { + values.max_filters_per_subscription = 2; + values.max_filter_complexity = 3; + values.max_subscriptions_per_connection = 4; + values.max_search_query_bytes = 32; + values.max_search_tokens = 2; + values.max_future_seconds = 10; + }); + + assert_eq!(limits.validate_filters(2, 3), Ok(())); + assert_eq!( + limits.validate_filters(3, 3).expect_err("filters").kind(), + RuntimeLimitKind::FiltersPerSubscription + ); + assert_eq!( + limits + .validate_filters(2, 4) + .expect_err("complexity") + .kind(), + RuntimeLimitKind::FilterComplexity + ); + assert_eq!(limits.validate_subscription_count(4), Ok(())); + assert_eq!( + limits + .validate_subscription_count(5) + .expect_err("subscriptions") + .kind(), + RuntimeLimitKind::SubscriptionsPerConnection + ); + assert_eq!(limits.validate_search_query("one two"), Ok(())); + assert_eq!( + limits + .validate_search_query("123456789012345678901234567890123") + .expect_err("search bytes") + .kind(), + RuntimeLimitKind::SearchQueryBytes + ); + assert_eq!( + limits + .validate_search_query("a b c") + .expect_err("search tokens") + .kind(), + RuntimeLimitKind::SearchTokens + ); + assert_eq!( + limits + .validate_event_timestamp( + &event_with(vec![], "", UnixTimestamp::new(111)), + UnixTimestamp::new(100), + ) + .expect_err("future") + .kind(), + RuntimeLimitKind::FutureSeconds + ); + assert_eq!( + limits.validate_event_timestamp( + &event_with(vec![], "", UnixTimestamp::new(100)), + UnixTimestamp::new(100), + ), + Ok(()) + ); + } + + #[test] + fn runtime_limit_violation_reports_stable_values() { + let violation = limits_with(|values| values.max_search_tokens = 1) + .validate_search_query("one two") + .expect_err("tokens"); + + assert_eq!(violation.kind(), RuntimeLimitKind::SearchTokens); + assert_eq!(violation.actual(), 2); + assert_eq!(violation.maximum(), 1); + assert_eq!(violation.to_string(), "search tokens exceeded: 2 > 1"); + assert_eq!( + [ + RuntimeLimitKind::EventBytes.as_str(), + RuntimeLimitKind::ContentBytes.as_str(), + RuntimeLimitKind::TagsPerEvent.as_str(), + RuntimeLimitKind::TagValuesPerTag.as_str(), + RuntimeLimitKind::TagValueBytes.as_str(), + RuntimeLimitKind::FiltersPerSubscription.as_str(), + RuntimeLimitKind::SubscriptionsPerConnection.as_str(), + RuntimeLimitKind::SearchQueryBytes.as_str(), + RuntimeLimitKind::SearchTokens.as_str(), + RuntimeLimitKind::FilterComplexity.as_str(), + RuntimeLimitKind::FutureSeconds.as_str(), + ], + [ + "event bytes", + "content bytes", + "tags per event", + "tag values per tag", + "tag value bytes", + "filters per subscription", + "subscriptions per connection", + "search query bytes", + "search tokens", + "filter complexity", + "future seconds", + ] + ); + assert_eq!( + RuntimeLimitKind::FutureSeconds.to_string(), + "future seconds" + ); + } + + fn limits_with(update: impl FnOnce(&mut RuntimeLimitValues)) -> RuntimeLimits { + let mut values = RuntimeLimitValues::default(); + update(&mut values); + RuntimeLimits::from_values(values).expect("limits") + } + + fn event_with(tags: Vec<Tag>, content: &str, created_at: UnixTimestamp) -> Event { + Event::new( + EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("id"), + UnsignedEvent::new( + PublicKeyHex::new(&"1".repeat(PublicKeyHex::HEX_LENGTH)).expect("pubkey"), + created_at, + Kind::new(30_402).expect("kind"), + tags, + content, + ), + SignatureHex::new(&"b".repeat(SignatureHex::HEX_LENGTH)).expect("sig"), + ) + } +}