lib

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

commit 7a6f7d64b4d33642183185b6340c5acc2ce73d63
parent b645ff39a4f0951cee01554ad9b07aad25dbe93f
Author: triesap <tyson@radroots.org>
Date:   Sat, 11 Jul 2026 07:33:34 +0000

transport: carry delivery payloads

- add RadrootsTransportPayload with signed-event JSON, mesh CBOR frame, and opaque bytes contracts

- replace delivery request payload_digest with payload objects and validate derived lowercase SHA-256 digests

- route runtime dispatch through payload construction and remove digest-only runtime payloads

- keep Reticulum preview inert while accepting typed payload-carrying requests

- add source-boundary guards against digest-only delivery request paths

Validation:

- cargo fmt --all --check

- cargo check --workspace --all-targets

- cargo test -p radroots_transport --all-features

- cargo test -p radroots_transport --no-default-features

- cargo test -p radroots_transport_reticulum --all-features

- cargo test -p radroots_runtime --all-features

- git diff --check

Diffstat:
Mcrates/runtime/src/transport.rs | 63++++++++++++++++++++++++++++++++++++++++++---------------------
Mcrates/transport/src/delivery.rs | 9+++++----
Mcrates/transport/src/error.rs | 18++++++++++++++++++
Mcrates/transport/src/lib.rs | 2++
Acrates/transport/src/payload.rs | 204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport/tests/source_boundary.rs | 45+++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport/tests/transport.rs | 102++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/transport_reticulum/tests/reticulum_preview.rs | 12++++++++----
8 files changed, 423 insertions(+), 32 deletions(-)

diff --git a/crates/runtime/src/transport.rs b/crates/runtime/src/transport.rs @@ -8,8 +8,8 @@ use radroots_events::draft::RadrootsSignedEvent; use radroots_transport::RadrootsTransportTargetReceipt; use radroots_transport::{ RadrootsTransport, RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, - RadrootsTransportError, RadrootsTransportKind, RadrootsTransportSatisfactionPolicy, - RadrootsTransportTarget, RadrootsTransportTargetSet, + RadrootsTransportError, RadrootsTransportKind, RadrootsTransportPayload, + RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetSet, }; use thiserror::Error; @@ -57,14 +57,18 @@ impl From<RadrootsTransportError> for RadrootsRuntimeTransportError { #[derive(Clone, Debug, PartialEq, Eq)] pub enum RadrootsRuntimeTransportPayload { SignedNostrEvent(RadrootsSignedEvent), - DigestOnly(String), + OpaqueBytes { label: String, bytes: Vec<u8> }, } impl RadrootsRuntimeTransportPayload { - pub fn digest(&self) -> String { + pub fn transport_payload(&self) -> Result<RadrootsTransportPayload, RadrootsTransportError> { match self { - Self::SignedNostrEvent(event) => event.id.clone(), - Self::DigestOnly(value) => value.clone(), + Self::SignedNostrEvent(event) => { + RadrootsTransportPayload::signed_event_json(&event.id, &event.raw_json) + } + Self::OpaqueBytes { label, bytes } => { + RadrootsTransportPayload::opaque_bytes(label, bytes) + } } } } @@ -99,13 +103,15 @@ impl RadrootsRuntimeTransportDispatchRequest { }) } - pub fn transport_delivery_request(&self) -> RadrootsTransportDeliveryRequest { - RadrootsTransportDeliveryRequest::new( + pub fn transport_delivery_request( + &self, + ) -> Result<RadrootsTransportDeliveryRequest, RadrootsRuntimeTransportError> { + Ok(RadrootsTransportDeliveryRequest::new( self.request_id.clone(), - self.payload.digest(), + self.payload.transport_payload()?, self.target_set.clone(), self.satisfaction_policy.clone(), - ) + )) } } @@ -470,7 +476,7 @@ impl<'a> RadrootsRuntimeDeliveryWorker<'a> { job.now_ms, )?; let receipt = transport - .deliver(request.transport_delivery_request()) + .deliver(request.transport_delivery_request()?) .await .map_err(|error| RadrootsRuntimeTransportError::Transport { kind: kind.canonical_label(), @@ -708,6 +714,13 @@ mod tests { RadrootsTransportTarget::new(kind, uri).expect("target") } + fn opaque_payload() -> RadrootsRuntimeTransportPayload { + RadrootsRuntimeTransportPayload::OpaqueBytes { + label: "runtime-test-payload".to_owned(), + bytes: b"runtime payload".to_vec(), + } + } + #[cfg(feature = "transport-workers")] fn signed_event() -> RadrootsSignedEvent { RadrootsSignedEvent::new(RadrootsSignedEventParts { @@ -770,14 +783,18 @@ mod tests { .expect("nostr transport"); let request = RadrootsRuntimeTransportDispatchRequest::new( "nostr-delivery", - RadrootsRuntimeTransportPayload::DigestOnly("sha256:event".to_owned()), + opaque_payload(), vec![target(RadrootsTransportKind::Nostr, "wss://relay.example")], RadrootsTransportSatisfactionPolicy::any_accepted(), 1_000, ) .expect("request"); let receipt = transport - .deliver(request.transport_delivery_request()) + .deliver( + request + .transport_delivery_request() + .expect("delivery request"), + ) .await .expect("receipt"); let status = transport.status().await.expect("status"); @@ -817,7 +834,7 @@ mod tests { .expect("reticulum transport"); let request = RadrootsRuntimeTransportDispatchRequest::new( "reticulum-delivery", - RadrootsRuntimeTransportPayload::DigestOnly("sha256:event".to_owned()), + opaque_payload(), vec![target( RadrootsTransportKind::Reticulum, "reticulum:preview-unavailable", @@ -827,7 +844,11 @@ mod tests { ) .expect("request"); let receipt = transport - .deliver(request.transport_delivery_request()) + .deliver( + request + .transport_delivery_request() + .expect("delivery request"), + ) .await .expect("receipt"); let status = transport.status().await.expect("status"); @@ -903,7 +924,7 @@ mod tests { let receipt = worker .execute_job(RadrootsRuntimeDeliveryJob { outbox_event_id: 42, - payload: RadrootsRuntimeTransportPayload::DigestOnly("sha256:event".to_owned()), + payload: opaque_payload(), plans: vec![RadrootsRuntimeDeliveryPlan { delivery_plan_id: 7, satisfaction_policy: RadrootsTransportSatisfactionPolicy::any_accepted(), @@ -947,7 +968,7 @@ mod tests { let receipt = worker .execute_job(RadrootsRuntimeDeliveryJob { outbox_event_id: 42, - payload: RadrootsRuntimeTransportPayload::DigestOnly("sha256:event".to_owned()), + payload: opaque_payload(), plans: vec![RadrootsRuntimeDeliveryPlan { delivery_plan_id: 7, satisfaction_policy: RadrootsTransportSatisfactionPolicy::no_wait(), @@ -994,7 +1015,7 @@ mod tests { let receipt = worker .execute_job(RadrootsRuntimeDeliveryJob { outbox_event_id: 42, - payload: RadrootsRuntimeTransportPayload::DigestOnly("sha256:event".to_owned()), + payload: opaque_payload(), plans: vec![RadrootsRuntimeDeliveryPlan { delivery_plan_id: 7, satisfaction_policy: RadrootsTransportSatisfactionPolicy::required_targets( @@ -1049,7 +1070,7 @@ mod tests { let receipt = worker .execute_job(RadrootsRuntimeDeliveryJob { outbox_event_id: 42, - payload: RadrootsRuntimeTransportPayload::DigestOnly("sha256:event".to_owned()), + payload: opaque_payload(), plans: vec![RadrootsRuntimeDeliveryPlan { delivery_plan_id: 7, satisfaction_policy: RadrootsTransportSatisfactionPolicy::any_delivered(), @@ -1092,7 +1113,7 @@ mod tests { let receipt = worker .execute_job(RadrootsRuntimeDeliveryJob { outbox_event_id: 42, - payload: RadrootsRuntimeTransportPayload::DigestOnly("sha256:event".to_owned()), + payload: opaque_payload(), plans: vec![RadrootsRuntimeDeliveryPlan { delivery_plan_id: 7, satisfaction_policy: RadrootsTransportSatisfactionPolicy::quorum_accepted(2), @@ -1145,7 +1166,7 @@ mod tests { let receipt = worker .execute_job(RadrootsRuntimeDeliveryJob { outbox_event_id: 42, - payload: RadrootsRuntimeTransportPayload::DigestOnly("sha256:event".to_owned()), + payload: opaque_payload(), plans: vec![RadrootsRuntimeDeliveryPlan { delivery_plan_id: 7, satisfaction_policy: RadrootsTransportSatisfactionPolicy::any_accepted(), diff --git a/crates/transport/src/delivery.rs b/crates/transport/src/delivery.rs @@ -1,6 +1,7 @@ use crate::{ RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportOutcome, - RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetSet, + RadrootsTransportPayload, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, + RadrootsTransportTargetSet, }; use alloc::collections::BTreeSet; use alloc::string::String; @@ -164,7 +165,7 @@ fn validate_required_targets( #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTransportDeliveryRequest { pub request_id: String, - pub payload_digest: String, + pub payload: RadrootsTransportPayload, pub target_set: RadrootsTransportTargetSet, pub satisfaction_policy: RadrootsTransportSatisfactionPolicy, } @@ -172,13 +173,13 @@ pub struct RadrootsTransportDeliveryRequest { impl RadrootsTransportDeliveryRequest { pub fn new( request_id: impl Into<String>, - payload_digest: impl Into<String>, + payload: RadrootsTransportPayload, target_set: RadrootsTransportTargetSet, satisfaction_policy: RadrootsTransportSatisfactionPolicy, ) -> Self { Self { request_id: request_id.into(), - payload_digest: payload_digest.into(), + payload, target_set, satisfaction_policy, } diff --git a/crates/transport/src/error.rs b/crates/transport/src/error.rs @@ -16,6 +16,14 @@ pub enum RadrootsTransportError { InvalidSatisfactionPolicy, EmptyRequiredTargetSet, DuplicateRequiredTargetFingerprint, + EmptyPayloadId, + InvalidPayloadId, + EmptyPayloadLabel, + InvalidPayloadLabel, + EmptyPayloadBytes, + InvalidPayloadBytes, + InvalidPayloadDigest, + PayloadDigestMismatch, } impl fmt::Display for RadrootsTransportError { @@ -43,6 +51,16 @@ impl fmt::Display for RadrootsTransportError { Self::DuplicateRequiredTargetFingerprint => { f.write_str("transport required target set contains duplicate fingerprints") } + Self::EmptyPayloadId => f.write_str("transport payload id is empty"), + Self::InvalidPayloadId => f.write_str("transport payload id is invalid"), + Self::EmptyPayloadLabel => f.write_str("transport payload label is empty"), + Self::InvalidPayloadLabel => f.write_str("transport payload label is invalid"), + Self::EmptyPayloadBytes => f.write_str("transport payload bytes are empty"), + Self::InvalidPayloadBytes => f.write_str("transport payload bytes are invalid"), + Self::InvalidPayloadDigest => f.write_str("transport payload digest is invalid"), + Self::PayloadDigestMismatch => { + f.write_str("transport payload digest does not match payload bytes") + } } } } diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs @@ -7,6 +7,7 @@ mod delivery; mod error; mod kind; mod message; +mod payload; mod status; mod target; mod transport; @@ -22,6 +23,7 @@ pub use message::{ RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_PREVIEW_SCOPE_ID, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, }; +pub use payload::RadrootsTransportPayload; pub use status::{ RadrootsTransportDeliveryTargetStatus, RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportStatus, diff --git a/crates/transport/src/payload.rs b/crates/transport/src/payload.rs @@ -0,0 +1,204 @@ +use crate::RadrootsTransportError; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use sha2::{Digest, Sha256}; + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsTransportPayload { + SignedEventJson { + event_id: String, + raw_json: String, + digest: String, + }, + MeshFrameCbor { + message_id: String, + bytes: Vec<u8>, + digest: String, + }, + OpaqueBytes { + label: String, + bytes: Vec<u8>, + digest: String, + }, +} + +impl RadrootsTransportPayload { + pub fn signed_event_json( + event_id: impl AsRef<str>, + raw_json: impl AsRef<str>, + ) -> Result<Self, RadrootsTransportError> { + let event_id = validate_hex_id(event_id.as_ref())?; + let raw_json = validate_raw_json(raw_json.as_ref())?; + let digest = sha256_hex(raw_json.as_bytes()); + Ok(Self::SignedEventJson { + event_id, + raw_json, + digest, + }) + } + + pub fn signed_event_json_with_digest( + event_id: impl AsRef<str>, + raw_json: impl AsRef<str>, + digest: impl AsRef<str>, + ) -> Result<Self, RadrootsTransportError> { + let payload = Self::signed_event_json(event_id, raw_json)?; + validate_supplied_digest(payload.digest(), digest.as_ref())?; + Ok(payload) + } + + pub fn mesh_frame_cbor( + message_id: impl AsRef<str>, + bytes: impl AsRef<[u8]>, + ) -> Result<Self, RadrootsTransportError> { + let message_id = validate_token_id(message_id.as_ref())?; + let bytes = validate_bytes(bytes.as_ref())?; + let digest = sha256_hex(bytes.as_slice()); + Ok(Self::MeshFrameCbor { + message_id, + bytes, + digest, + }) + } + + pub fn mesh_frame_cbor_with_digest( + message_id: impl AsRef<str>, + bytes: impl AsRef<[u8]>, + digest: impl AsRef<str>, + ) -> Result<Self, RadrootsTransportError> { + let payload = Self::mesh_frame_cbor(message_id, bytes)?; + validate_supplied_digest(payload.digest(), digest.as_ref())?; + Ok(payload) + } + + pub fn opaque_bytes( + label: impl AsRef<str>, + bytes: impl AsRef<[u8]>, + ) -> Result<Self, RadrootsTransportError> { + let label = validate_label(label.as_ref())?; + let bytes = validate_bytes(bytes.as_ref())?; + let digest = sha256_hex(bytes.as_slice()); + Ok(Self::OpaqueBytes { + label, + bytes, + digest, + }) + } + + pub fn opaque_bytes_with_digest( + label: impl AsRef<str>, + bytes: impl AsRef<[u8]>, + digest: impl AsRef<str>, + ) -> Result<Self, RadrootsTransportError> { + let payload = Self::opaque_bytes(label, bytes)?; + validate_supplied_digest(payload.digest(), digest.as_ref())?; + Ok(payload) + } + + pub fn digest(&self) -> &str { + match self { + Self::SignedEventJson { digest, .. } + | Self::MeshFrameCbor { digest, .. } + | Self::OpaqueBytes { digest, .. } => digest.as_str(), + } + } + + pub fn payload_kind(&self) -> &'static str { + match self { + Self::SignedEventJson { .. } => "signed_event_json", + Self::MeshFrameCbor { .. } => "mesh_frame_cbor", + Self::OpaqueBytes { .. } => "opaque_bytes", + } + } +} + +fn validate_hex_id(raw: &str) -> Result<String, RadrootsTransportError> { + if raw.len() != 64 || !raw.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(RadrootsTransportError::InvalidPayloadId); + } + let lowered = raw.to_ascii_lowercase(); + if raw != lowered { + return Err(RadrootsTransportError::InvalidPayloadId); + } + Ok(lowered) +} + +fn validate_token_id(raw: &str) -> Result<String, RadrootsTransportError> { + if raw.is_empty() { + return Err(RadrootsTransportError::EmptyPayloadId); + } + if raw != raw.trim() + || raw + .chars() + .any(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))) + { + return Err(RadrootsTransportError::InvalidPayloadId); + } + Ok(raw.to_string()) +} + +fn validate_label(raw: &str) -> Result<String, RadrootsTransportError> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(RadrootsTransportError::EmptyPayloadLabel); + } + if trimmed.chars().any(char::is_control) { + return Err(RadrootsTransportError::InvalidPayloadLabel); + } + Ok(trimmed.to_string()) +} + +fn validate_raw_json(raw: &str) -> Result<String, RadrootsTransportError> { + if raw.is_empty() { + return Err(RadrootsTransportError::EmptyPayloadBytes); + } + if raw != raw.trim() + || raw.chars().any(char::is_control) + || !raw.starts_with('{') + || !raw.ends_with('}') + { + return Err(RadrootsTransportError::InvalidPayloadBytes); + } + Ok(raw.to_string()) +} + +fn validate_bytes(raw: &[u8]) -> Result<Vec<u8>, RadrootsTransportError> { + if raw.is_empty() { + return Err(RadrootsTransportError::EmptyPayloadBytes); + } + Ok(raw.to_vec()) +} + +fn validate_supplied_digest(expected: &str, supplied: &str) -> Result<(), RadrootsTransportError> { + validate_digest(supplied)?; + if supplied != expected { + return Err(RadrootsTransportError::PayloadDigestMismatch); + } + Ok(()) +} + +fn validate_digest(raw: &str) -> Result<(), RadrootsTransportError> { + if raw.len() != 64 + || !raw + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(RadrootsTransportError::InvalidPayloadDigest); + } + Ok(()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex_encode(Sha256::digest(bytes).as_slice()) +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} diff --git a/crates/transport/tests/source_boundary.rs b/crates/transport/tests/source_boundary.rs @@ -29,6 +29,9 @@ const CORE_STATUS_CONTRACT_SOURCE_ROOTS: &[&str] = &["transport/src", "transport const CORE_TRANSPORT_CONTRACT_SOURCE_ROOTS: &[&str] = &["transport/src"]; +const DELIVERY_PAYLOAD_CONTRACT_SOURCE_ROOTS: &[&str] = + &["transport/src", "runtime/src", "transport_reticulum/src"]; + const FORBIDDEN_TRANSPORT_CONCEPTS: &[ForbiddenConcept] = &[ ForbiddenConcept { pattern: "\"radrootsd_proxy\"", @@ -177,6 +180,17 @@ const FORBIDDEN_CORE_TRANSPORT_CONCEPTS: &[ForbiddenConcept] = &[ }, ]; +const FORBIDDEN_DELIVERY_PAYLOAD_CONCEPTS: &[ForbiddenConcept] = &[ + ForbiddenConcept { + pattern: "payload_digest", + reason: "delivery requests must carry RadrootsTransportPayload instead of digest-only fields", + }, + ForbiddenConcept { + pattern: "DigestOnly", + reason: "runtime dispatch must not retain a digest-only payload path", + }, +]; + #[test] fn transport_hardening_sources_reject_removed_protocol_identifiers() { let crates_root = Path::new(env!("CARGO_MANIFEST_DIR")) @@ -308,6 +322,37 @@ fn core_transport_sources_reject_relay_shaped_public_contracts() { } #[test] +fn delivery_request_sources_require_payload_objects() { + let crates_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("transport crate parent"); + let mut findings = Vec::new(); + + for relative_root in DELIVERY_PAYLOAD_CONTRACT_SOURCE_ROOTS { + for path in rust_source_files(crates_root.join(relative_root).as_path()) { + let source_raw = read_source(path.as_path()); + let source = production_source(source_raw.as_str()); + let relative_path = relative_path(crates_root, path.as_path()); + + for concept in FORBIDDEN_DELIVERY_PAYLOAD_CONCEPTS { + if contains_forbidden_concept(source, concept.pattern) { + findings.push(format!( + "{} contains digest-only delivery concept `{}`: {}", + relative_path, concept.pattern, concept.reason + )); + } + } + } + } + + assert!( + findings.is_empty(), + "delivery payload source-boundary violations:\n{}", + findings.join("\n") + ); +} + +#[test] fn runtime_transport_registry_uses_core_transport_contract() { let crates_root = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -4,12 +4,17 @@ use radroots_transport::{ RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportOutcome, - RadrootsTransportOutcomeKind, RadrootsTransportSatisfactionClass, + RadrootsTransportOutcomeKind, RadrootsTransportPayload, RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, RadrootsTransportTargetUri, }; +fn opaque_payload() -> RadrootsTransportPayload { + RadrootsTransportPayload::opaque_bytes("transport-test-payload", b"transport payload") + .expect("payload") +} + #[test] fn target_fingerprints_are_stable_and_transport_scoped() { let nostr_upper = @@ -175,6 +180,7 @@ fn satisfaction_policy_counts_target_statuses() { } #[test] +#[cfg(feature = "serde")] fn transport_status_models_canonical_configuration_and_delivery_usability() { let status = RadrootsTransportStatus::new( RadrootsTransportKind::Nostr, @@ -252,13 +258,14 @@ fn deferred_transport_outcomes_are_terminal_but_not_satisfied() { } #[test] +#[cfg(feature = "serde")] fn request_models_round_trip_with_serde() { let target = RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example") .expect("target"); let target_set = RadrootsTransportTargetSet::new(vec![target]).expect("target set"); let request = RadrootsTransportDeliveryRequest::new( "req-1", - "sha256:payload", + opaque_payload(), target_set, RadrootsTransportSatisfactionPolicy::any_accepted(), ); @@ -271,6 +278,95 @@ fn request_models_round_trip_with_serde() { } #[test] +fn payload_contract_derives_and_validates_digests() { + let event_id = "a".repeat(64); + let signed = RadrootsTransportPayload::signed_event_json( + event_id.as_str(), + "{\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}", + ) + .expect("signed event payload"); + assert_eq!(signed.payload_kind(), "signed_event_json"); + assert_eq!(signed.digest().len(), 64); + assert!( + signed + .digest() + .bytes() + .all(|byte| { byte.is_ascii_digit() || matches!(byte, b'a'..=b'f') }) + ); + assert_eq!( + RadrootsTransportPayload::signed_event_json_with_digest( + event_id.as_str(), + "{\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}", + signed.digest(), + ) + .expect("signed payload with digest"), + signed + ); + + let mesh = + RadrootsTransportPayload::mesh_frame_cbor("mesh.message-1", [1_u8, 2, 3]).expect("mesh"); + assert_eq!( + RadrootsTransportPayload::mesh_frame_cbor_with_digest( + "mesh.message-1", + [1_u8, 2, 3], + mesh.digest(), + ) + .expect("mesh with digest"), + mesh + ); + + let opaque = RadrootsTransportPayload::opaque_bytes("operator note", b"bytes").expect("opaque"); + assert_eq!( + RadrootsTransportPayload::opaque_bytes_with_digest( + "operator note", + b"bytes", + opaque.digest(), + ) + .expect("opaque with digest"), + opaque + ); +} + +#[test] +fn payload_contract_rejects_invalid_ids_bytes_labels_and_digests() { + assert_eq!( + RadrootsTransportPayload::signed_event_json("A".repeat(64), "{}") + .expect_err("uppercase event id"), + RadrootsTransportError::InvalidPayloadId + ); + assert_eq!( + RadrootsTransportPayload::signed_event_json("a".repeat(64), " [] ") + .expect_err("non-object json"), + RadrootsTransportError::InvalidPayloadBytes + ); + assert_eq!( + RadrootsTransportPayload::mesh_frame_cbor("mesh message", [1_u8]) + .expect_err("space in message id"), + RadrootsTransportError::InvalidPayloadId + ); + assert_eq!( + RadrootsTransportPayload::mesh_frame_cbor("mesh-message", []) + .expect_err("empty mesh bytes"), + RadrootsTransportError::EmptyPayloadBytes + ); + assert_eq!( + RadrootsTransportPayload::opaque_bytes("bad\u{0007}", b"bytes") + .expect_err("control character label"), + RadrootsTransportError::InvalidPayloadLabel + ); + assert_eq!( + RadrootsTransportPayload::opaque_bytes_with_digest("label", b"bytes", "f".repeat(64)) + .expect_err("digest mismatch"), + RadrootsTransportError::PayloadDigestMismatch + ); + assert_eq!( + RadrootsTransportPayload::opaque_bytes_with_digest("label", b"bytes", "F".repeat(64)) + .expect_err("uppercase digest"), + RadrootsTransportError::InvalidPayloadDigest + ); +} + +#[test] fn fingerprint_parser_rejects_non_sha256_hex() { assert_eq!( RadrootsTransportTargetFingerprint::parse("abc").expect_err("short fingerprint"), @@ -870,7 +966,7 @@ fn neutral_transport_trait_covers_status_delivery_and_fetch() { let delivery = futures::executor::block_on(transport.deliver(RadrootsTransportDeliveryRequest::new( "deliver-1", - "sha256:payload", + opaque_payload(), target_set.clone(), RadrootsTransportSatisfactionPolicy::all_delivered(), ))) diff --git a/crates/transport_reticulum/tests/reticulum_preview.rs b/crates/transport_reticulum/tests/reticulum_preview.rs @@ -3,8 +3,8 @@ use radroots_transport::{ RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransport, RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, RadrootsTransportFetchRequest, RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportMeshScopeId, - RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, - RadrootsTransportTarget, RadrootsTransportTargetSet, + RadrootsTransportPayload, RadrootsTransportSatisfactionClass, + RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetSet, }; use radroots_transport_reticulum::{ RadrootsReticulumPreviewAgentEndpoint, RadrootsReticulumPreviewBehavior, @@ -35,12 +35,16 @@ fn nostr_target() -> RadrootsTransportTarget { fn delivery_request(targets: Vec<RadrootsTransportTarget>) -> RadrootsTransportDeliveryRequest { RadrootsTransportDeliveryRequest::new( "reticulum-preview-delivery", - "sha256:preview-payload", + preview_payload(), RadrootsTransportTargetSet::new(targets).expect("target set"), RadrootsTransportSatisfactionPolicy::any_accepted(), ) } +fn preview_payload() -> RadrootsTransportPayload { + RadrootsTransportPayload::mesh_frame_cbor("preview-message", [1_u8, 2, 3]).expect("payload") +} + #[test] fn default_profile_is_configured_preview_unavailable_and_rejecting() { let profile = RadrootsReticulumPreviewProfile::default(); @@ -293,7 +297,7 @@ fn core_transport_trait_reports_preview_status_delivery_and_fetch() { &transport, RadrootsTransportDeliveryRequest::new( "core-delivery", - "sha256:preview-payload", + preview_payload(), target_set.clone(), RadrootsTransportSatisfactionPolicy::any_accepted(), ),