lib

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

commit 06aac3d5f9b573c8923ee094eb6efe2e3ad52a11
parent cf75f8b9d49ca9c0a25c1477ccc16cd26b457679
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 07:08:38 +0000

transport: enforce foundational resource bounds

- define the exact Phase 1 transport resource maxima
- reject oversized targets and required-target collections before set work
- bound request identifiers and every foundational payload variant
- cover exact and one-over behavior across constructors and adapters

Diffstat:
Mcrates/transport/src/delivery.rs | 19++++++++++++++-----
Mcrates/transport/src/error.rs | 9+++++++++
Mcrates/transport/src/lib.rs | 11+++++++++++
Acrates/transport/src/limits.rs | 30++++++++++++++++++++++++++++++
Mcrates/transport/src/payload.rs | 42+++++++++++++++++++++++++++++++++++++-----
Mcrates/transport/src/target.rs | 36++++++++++++++++++++++++++++++++----
Mcrates/transport/tests/transport.rs | 259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/transport_nostr/src/outbox.rs | 9+++++++++
8 files changed, 391 insertions(+), 24 deletions(-)

diff --git a/crates/transport/src/delivery.rs b/crates/transport/src/delivery.rs @@ -1,4 +1,5 @@ use crate::{ + RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, RADROOTS_TRANSPORT_TARGET_MAX_COUNT, RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportPayload, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetSet, @@ -7,7 +8,8 @@ use alloc::collections::{BTreeMap, BTreeSet}; use alloc::string::String; use alloc::vec::Vec; -pub const RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES: usize = 256; +pub const RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES: usize = + RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -295,6 +297,11 @@ fn validate_required_targets( if targets.is_empty() { return Err(RadrootsTransportError::EmptyRequiredTargetSet); } + crate::limits::ensure_resource_limit( + "required_target_count", + targets.len(), + RADROOTS_TRANSPORT_TARGET_MAX_COUNT, + )?; let mut fingerprints = BTreeSet::new(); for target in targets { if !fingerprints.insert(target.as_str()) { @@ -365,10 +372,12 @@ fn validate_delivery_request_id(value: &str) -> Result<(), RadrootsTransportErro if value.is_empty() { return Err(RadrootsTransportError::EmptyDeliveryRequestId); } - if value != value.trim() - || value.chars().any(char::is_control) - || value.len() > RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES - { + crate::limits::ensure_resource_limit( + "delivery_request_id", + value.len(), + RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES, + )?; + if value != value.trim() || value.chars().any(char::is_control) { return Err(RadrootsTransportError::InvalidDeliveryRequestId); } Ok(()) diff --git a/crates/transport/src/error.rs b/crates/transport/src/error.rs @@ -38,6 +38,11 @@ pub enum RadrootsTransportError { InvalidPayloadSignature, InvalidPayloadDigest, PayloadDigestMismatch, + ResourceLimitExceeded { + field: &'static str, + max: usize, + actual: usize, + }, } impl fmt::Display for RadrootsTransportError { @@ -111,6 +116,10 @@ impl fmt::Display for RadrootsTransportError { Self::PayloadDigestMismatch => { f.write_str("transport payload digest does not match payload bytes") } + Self::ResourceLimitExceeded { field, max, actual } => write!( + f, + "transport resource `{field}` exceeds maximum {max}: observed {actual}" + ), } } } diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs @@ -7,6 +7,7 @@ extern crate alloc; mod delivery; mod error; mod kind; +mod limits; mod message; mod payload; mod reticulum; @@ -24,6 +25,16 @@ pub use kind::{ RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity, RadrootsTransportImplementationState, RadrootsTransportKind, }; +pub use limits::{ + RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES, RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, + RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT, RADROOTS_TRANSPORT_FETCH_FILTER_MAX_BYTES, + RADROOTS_TRANSPORT_FETCH_FILTER_MAX_COUNT, RADROOTS_TRANSPORT_FETCH_FILTERS_MAX_BYTES, + RADROOTS_TRANSPORT_FETCH_RAW_ITEM_MAX_COUNT, RADROOTS_TRANSPORT_FETCH_RAW_JSON_MAX_BYTES, + RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES, + RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES, RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES, + RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES, RADROOTS_TRANSPORT_TARGET_MAX_COUNT, + RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES, RADROOTS_TRANSPORT_TOTAL_DEADLINE_MAX_MS, +}; pub use message::{ RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, diff --git a/crates/transport/src/limits.rs b/crates/transport/src/limits.rs @@ -0,0 +1,30 @@ +pub const RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES: usize = 256 * 1024; +pub const RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES: usize = 64 * 1024; +pub const RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES: usize = + RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES; +pub const RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES: usize = 2_048; +pub const RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES: usize = 256; +pub const RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES: usize = + RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES; +pub const RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES: usize = + RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES; +pub const RADROOTS_TRANSPORT_TARGET_MAX_COUNT: usize = 16; +pub const RADROOTS_TRANSPORT_FETCH_FILTER_MAX_COUNT: usize = 16; +pub const RADROOTS_TRANSPORT_FETCH_FILTER_MAX_BYTES: usize = 64 * 1024; +pub const RADROOTS_TRANSPORT_FETCH_FILTERS_MAX_BYTES: usize = 1024 * 1024; +pub const RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT: usize = 1_000; +pub const RADROOTS_TRANSPORT_FETCH_RAW_ITEM_MAX_COUNT: usize = 4_096; +pub const RADROOTS_TRANSPORT_FETCH_RAW_JSON_MAX_BYTES: usize = 64 * 1024 * 1024; +pub const RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES: usize = 4_096; +pub const RADROOTS_TRANSPORT_TOTAL_DEADLINE_MAX_MS: u64 = 30_000; + +pub(crate) fn ensure_resource_limit( + field: &'static str, + actual: usize, + max: usize, +) -> Result<(), crate::RadrootsTransportError> { + if actual > max { + return Err(crate::RadrootsTransportError::ResourceLimitExceeded { field, max, actual }); + } + Ok(()) +} diff --git a/crates/transport/src/payload.rs b/crates/transport/src/payload.rs @@ -1,4 +1,8 @@ -use crate::RadrootsTransportError; +use crate::{ + RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES, + RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES, RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES, + RadrootsTransportError, limits::ensure_resource_limit, +}; use alloc::string::{String, ToString}; use alloc::vec::Vec; use sha2::{Digest, Sha256}; @@ -72,7 +76,11 @@ impl RadrootsTransportPayload { bytes: &[u8], ) -> Result<Self, RadrootsTransportError> { let message_id = validate_token_id(message_id)?; - let bytes = validate_bytes(bytes)?; + let bytes = validate_bytes( + "mesh_frame_cbor_bytes", + bytes, + RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES, + )?; let digest = sha256_hex(bytes.as_slice()); Ok(Self::MeshFrameCbor { message_id, @@ -112,7 +120,11 @@ impl RadrootsTransportPayload { fn validated_opaque_bytes(label: &str, bytes: &[u8]) -> Result<Self, RadrootsTransportError> { let label = validate_label(label)?; - let bytes = validate_bytes(bytes)?; + let bytes = validate_bytes( + "opaque_payload_bytes", + bytes, + RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES, + )?; let digest = sha256_hex(bytes.as_slice()); Ok(Self::OpaqueBytes { label, @@ -256,6 +268,11 @@ fn validate_token_id(raw: &str) -> Result<String, RadrootsTransportError> { if raw.is_empty() { return Err(RadrootsTransportError::EmptyPayloadId); } + ensure_resource_limit( + "payload_id", + raw.len(), + RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, + )?; if raw != raw.trim() || raw .chars() @@ -271,7 +288,12 @@ fn validate_label(raw: &str) -> Result<String, RadrootsTransportError> { if trimmed.is_empty() { return Err(RadrootsTransportError::EmptyPayloadLabel); } - if trimmed.chars().any(char::is_control) { + ensure_resource_limit( + "payload_label", + raw.len(), + RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, + )?; + if raw != trimmed || trimmed.chars().any(char::is_control) { return Err(RadrootsTransportError::InvalidPayloadLabel); } Ok(trimmed.to_string()) @@ -281,6 +303,11 @@ fn validate_raw_json(raw: &str) -> Result<String, RadrootsTransportError> { if raw.is_empty() { return Err(RadrootsTransportError::EmptyPayloadBytes); } + ensure_resource_limit( + "signed_event_json_bytes", + raw.len(), + RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES, + )?; if raw != raw.trim() || raw.chars().any(char::is_control) || !raw.starts_with('{') @@ -291,10 +318,15 @@ fn validate_raw_json(raw: &str) -> Result<String, RadrootsTransportError> { Ok(raw.to_string()) } -fn validate_bytes(raw: &[u8]) -> Result<Vec<u8>, RadrootsTransportError> { +fn validate_bytes( + field: &'static str, + raw: &[u8], + max: usize, +) -> Result<Vec<u8>, RadrootsTransportError> { if raw.is_empty() { return Err(RadrootsTransportError::EmptyPayloadBytes); } + ensure_resource_limit(field, raw.len(), max)?; Ok(raw.to_vec()) } diff --git a/crates/transport/src/target.rs b/crates/transport/src/target.rs @@ -1,6 +1,8 @@ use crate::{ - RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, RadrootsTransportError, - RadrootsTransportKind, + RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, + RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES, + RADROOTS_TRANSPORT_TARGET_MAX_COUNT, RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES, + RadrootsTransportError, RadrootsTransportKind, limits::ensure_resource_limit, }; use alloc::collections::BTreeSet; use alloc::format; @@ -15,11 +17,21 @@ pub struct RadrootsTransportTargetUri(String); impl RadrootsTransportTargetUri { pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + ensure_resource_limit( + "target_uri", + raw.as_ref().len(), + RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, + )?; let canonical = canonicalize_uri(raw.as_ref())?; Ok(Self(canonical)) } fn parse_nostr_relay(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { + ensure_resource_limit( + "target_uri", + raw.as_ref().len(), + RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, + )?; let canonical = canonicalize_nostr_relay_uri(raw.as_ref())?; Ok(Self(canonical)) } @@ -62,6 +74,11 @@ impl RadrootsTransportMeshScopeId { if value.is_empty() { return Err(RadrootsTransportError::EmptyTargetScope); } + ensure_resource_limit( + "target_scope", + value.len(), + RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES, + )?; if value != value.trim() || value .chars() @@ -104,11 +121,17 @@ pub struct RadrootsTransportTargetLabel(String); impl RadrootsTransportTargetLabel { pub fn parse(raw: impl AsRef<str>) -> Result<Self, RadrootsTransportError> { - let trimmed = raw.as_ref().trim(); + let raw = raw.as_ref(); + let trimmed = raw.trim(); if trimmed.is_empty() { return Err(RadrootsTransportError::EmptyTargetLabel); } - if trimmed.chars().any(char::is_control) { + ensure_resource_limit( + "target_label", + raw.len(), + RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES, + )?; + if raw != trimmed || trimmed.chars().any(char::is_control) { return Err(RadrootsTransportError::InvalidTargetLabel); } Ok(Self(trimmed.to_string())) @@ -383,6 +406,11 @@ impl RadrootsTransportTargetSet { if targets.is_empty() { return Err(RadrootsTransportError::EmptyTargetSet); } + ensure_resource_limit( + "target_count", + targets.len(), + RADROOTS_TRANSPORT_TARGET_MAX_COUNT, + )?; let mut fingerprints = BTreeSet::new(); for target in &targets { if !fingerprints.insert(target.fingerprint.as_str().to_string()) { diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -1,13 +1,16 @@ use radroots_transport::{ RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, - RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES, RadrootsTransport, - RadrootsTransportCapabilities, RadrootsTransportCapabilityAvailability, - RadrootsTransportCapabilityMaturity, RadrootsTransportDeliveryReceipt, - RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, - RadrootsTransportError, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, - RadrootsTransportFuture, RadrootsTransportImplementationState, RadrootsTransportKind, - RadrootsTransportMeshScopeId, RadrootsTransportOutcome, RadrootsTransportOutcomeKind, - RadrootsTransportPayload, RadrootsTransportSatisfactionClass, + RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES, RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, + RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES, + RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES, RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES, + RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES, RADROOTS_TRANSPORT_TARGET_MAX_COUNT, + RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES, RadrootsTransport, RadrootsTransportCapabilities, + RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity, + RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, + RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportFetchReceipt, + RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState, + RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportOutcome, + RadrootsTransportOutcomeKind, RadrootsTransportPayload, RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, RadrootsTransportTargetUri, @@ -18,6 +21,7 @@ use radroots_transport::{ use serde_json::Value; use std::borrow::ToOwned; use std::boxed::Box; +use std::format; use std::string::{String, ToString}; use std::vec; use std::vec::Vec; @@ -1734,7 +1738,7 @@ fn payload_contract_covers_all_validation_boundaries() { let signed = RadrootsTransportPayload::unchecked_signed_event_json("a".repeat(64), "{}") .expect("signed"); let mesh = RadrootsTransportPayload::mesh_frame_cbor("mesh", [1]).expect("mesh"); - let opaque = RadrootsTransportPayload::opaque_bytes(" label ", [2]).expect("opaque"); + let opaque = RadrootsTransportPayload::opaque_bytes("label", [2]).expect("opaque"); assert_eq!(signed.payload_kind(), "signed_event_json"); assert_eq!(mesh.payload_kind(), "mesh_frame_cbor"); assert_eq!(opaque.payload_kind(), "opaque_bytes"); @@ -1979,10 +1983,14 @@ fn target_contract_covers_parser_and_authority_boundaries() { ); } - let label = RadrootsTransportTargetLabel::parse(" Relay One ").expect("label"); + let label = RadrootsTransportTargetLabel::parse("Relay One").expect("label"); assert_eq!(label.as_str(), "Relay One"); assert_eq!(label.to_string(), "Relay One"); assert_eq!( + RadrootsTransportTargetLabel::parse(" Relay One ").expect_err("noncanonical label"), + RadrootsTransportError::InvalidTargetLabel + ); + assert_eq!( RadrootsTransportTargetLabel::parse("\u{7f}").expect_err("control label"), RadrootsTransportError::InvalidTargetLabel ); @@ -2065,6 +2073,232 @@ fn target_contract_covers_parser_and_authority_boundaries() { } #[test] +fn transport_bounds_targets_enforce_exact_and_one_over_before_set_work() { + let exact_uri = "a".repeat(RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES); + assert_eq!( + RadrootsTransportTargetUri::parse(exact_uri) + .expect("exact URI") + .as_str() + .len(), + RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES + ); + assert_eq!( + RadrootsTransportTargetUri::parse( + "a".repeat(RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES + 1) + ) + .expect_err("one-over URI"), + RadrootsTransportError::ResourceLimitExceeded { + field: "target_uri", + max: RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, + actual: RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES + 1, + } + ); + + assert_eq!( + RadrootsTransportMeshScopeId::parse("a".repeat(RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES)) + .expect("exact scope") + .as_str() + .len(), + RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES + ); + assert_eq!( + RadrootsTransportMeshScopeId::parse( + "a".repeat(RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES + 1) + ) + .expect_err("one-over scope"), + RadrootsTransportError::ResourceLimitExceeded { + field: "target_scope", + max: RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES, + actual: RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES + 1, + } + ); + + assert_eq!( + RadrootsTransportTargetLabel::parse("a".repeat(RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES)) + .expect("exact label") + .as_str() + .len(), + RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES + ); + assert_eq!( + RadrootsTransportTargetLabel::parse( + "a".repeat(RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES + 1) + ) + .expect_err("one-over label"), + RadrootsTransportError::ResourceLimitExceeded { + field: "target_label", + max: RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES, + actual: RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES + 1, + } + ); + + let exact_targets = (0..RADROOTS_TRANSPORT_TARGET_MAX_COUNT) + .map(|index| { + RadrootsTransportTarget::local(format!("local:target-{index}")).expect("bounded target") + }) + .collect::<Vec<_>>(); + assert_eq!( + RadrootsTransportTargetSet::new(exact_targets.clone()) + .expect("exact target set") + .len(), + RADROOTS_TRANSPORT_TARGET_MAX_COUNT + ); + let mut one_over_targets = exact_targets; + one_over_targets + .push(RadrootsTransportTarget::local("local:target-one-over").expect("one-over target")); + assert_eq!( + RadrootsTransportTargetSet::new(one_over_targets).expect_err("one-over target set"), + RadrootsTransportError::ResourceLimitExceeded { + field: "target_count", + max: RADROOTS_TRANSPORT_TARGET_MAX_COUNT, + actual: RADROOTS_TRANSPORT_TARGET_MAX_COUNT + 1, + } + ); + + let required_targets = (0..RADROOTS_TRANSPORT_TARGET_MAX_COUNT) + .map(|index| { + RadrootsTransportTargetFingerprint::parse(format!("{index:064x}")) + .expect("bounded fingerprint") + }) + .collect::<Vec<_>>(); + RadrootsTransportSatisfactionPolicy::required_targets( + RadrootsTransportSatisfactionClass::Accepted, + required_targets.clone(), + ) + .expect("exact required-target set"); + let mut one_over_required_targets = required_targets; + one_over_required_targets.push( + RadrootsTransportTargetFingerprint::parse(format!( + "{:064x}", + RADROOTS_TRANSPORT_TARGET_MAX_COUNT + )) + .expect("one-over fingerprint"), + ); + assert_eq!( + RadrootsTransportSatisfactionPolicy::required_targets( + RadrootsTransportSatisfactionClass::Accepted, + one_over_required_targets, + ) + .expect_err("one-over required-target set"), + RadrootsTransportError::ResourceLimitExceeded { + field: "required_target_count", + max: RADROOTS_TRANSPORT_TARGET_MAX_COUNT, + actual: RADROOTS_TRANSPORT_TARGET_MAX_COUNT + 1, + } + ); +} + +#[test] +fn transport_bounds_payloads_enforce_exact_and_one_over_before_copying() { + let exact_json = format!( + "{{{}}}", + "a".repeat(RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES - 2) + ); + RadrootsTransportPayload::unchecked_signed_event_json("a".repeat(64), &exact_json) + .expect("exact signed event JSON"); + let one_over_json = format!( + "{{{}}}", + "a".repeat(RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES - 1) + ); + assert_eq!( + RadrootsTransportPayload::unchecked_signed_event_json("a".repeat(64), &one_over_json) + .expect_err("one-over signed event JSON"), + RadrootsTransportError::ResourceLimitExceeded { + field: "signed_event_json_bytes", + max: RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES, + actual: RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES + 1, + } + ); + + RadrootsTransportPayload::mesh_frame_cbor( + "a".repeat(RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES), + vec![0; RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES], + ) + .expect("exact mesh payload"); + assert_eq!( + RadrootsTransportPayload::mesh_frame_cbor( + "mesh", + vec![0; RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES + 1], + ) + .expect_err("one-over mesh payload"), + RadrootsTransportError::ResourceLimitExceeded { + field: "mesh_frame_cbor_bytes", + max: RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES, + actual: RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES + 1, + } + ); + assert_eq!( + RadrootsTransportPayload::mesh_frame_cbor( + "a".repeat(RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES + 1), + [1], + ) + .expect_err("one-over payload id"), + RadrootsTransportError::ResourceLimitExceeded { + field: "payload_id", + max: RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, + actual: RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES + 1, + } + ); + + RadrootsTransportPayload::opaque_bytes( + "a".repeat(RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES), + vec![0; RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES], + ) + .expect("exact opaque payload"); + assert_eq!( + RadrootsTransportPayload::opaque_bytes( + "a".repeat(RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES + 1), + [1], + ) + .expect_err("one-over payload label"), + RadrootsTransportError::ResourceLimitExceeded { + field: "payload_label", + max: RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, + actual: RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES + 1, + } + ); + assert_eq!( + RadrootsTransportPayload::opaque_bytes( + "label", + vec![0; RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES + 1], + ) + .expect_err("one-over opaque payload"), + RadrootsTransportError::ResourceLimitExceeded { + field: "opaque_payload_bytes", + max: RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES, + actual: RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES + 1, + } + ); + + let target_set = RadrootsTransportTargetSet::new(vec![ + RadrootsTransportTarget::local("local:bounded-request").expect("request target"), + ]) + .expect("request target set"); + let payload = RadrootsTransportPayload::opaque_bytes("bounded", [1]).expect("request payload"); + RadrootsTransportDeliveryRequest::new( + "a".repeat(RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES), + payload.clone(), + target_set.clone(), + RadrootsTransportSatisfactionPolicy::no_wait(), + ) + .expect("exact request id"); + assert_eq!( + RadrootsTransportDeliveryRequest::new( + "a".repeat(RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES + 1), + payload, + target_set, + RadrootsTransportSatisfactionPolicy::no_wait(), + ) + .expect_err("one-over request id"), + RadrootsTransportError::ResourceLimitExceeded { + field: "delivery_request_id", + max: RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES, + actual: RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES + 1, + } + ); +} + +#[test] fn every_transport_error_has_a_stable_display_message() { let remaining = [ RadrootsTransportError::RequiredTargetNotRequested, @@ -2087,6 +2321,11 @@ fn every_transport_error_has_a_stable_display_message() { RadrootsTransportError::InvalidPayloadBytes, RadrootsTransportError::InvalidPayloadDigest, RadrootsTransportError::PayloadDigestMismatch, + RadrootsTransportError::ResourceLimitExceeded { + field: "fixture", + max: 1, + actual: 2, + }, ]; for error in remaining { assert!(!error.to_string().is_empty()); diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs @@ -835,6 +835,7 @@ fn transport_error_to_relay_error(error: RadrootsTransportError) -> RadrootsRela } RadrootsTransportError::EmptyTargetUri | RadrootsTransportError::InvalidTargetUri + | RadrootsTransportError::ResourceLimitExceeded { .. } | RadrootsTransportError::EmptyTargetSet | RadrootsTransportError::DuplicateTargetFingerprint | RadrootsTransportError::InvalidTargetFingerprint @@ -1528,6 +1529,14 @@ mod tests { RadrootsRelayTransportError::TransportContract(_) )); } + assert!(matches!( + transport_error_to_relay_error(RadrootsTransportError::ResourceLimitExceeded { + field: "fixture", + max: 1, + actual: 2, + }), + RadrootsRelayTransportError::TransportContract(_) + )); let payload_errors = [ RadrootsTransportError::EmptyPayloadId, RadrootsTransportError::InvalidPayloadId,