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:
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(),
),