lib

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

commit b8ed88d8cd99f3d1c3c84ee6925b621ecf4c49bf
parent 4468609560054cbea0c77a6b1f6726f3761033d7
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Jul 2026 02:23:15 +0000

transport: validate explicit publish outcomes

- reject zero transport publish request timeouts in the shared protocol
- require explicit target outcomes to match declared target identity sets
- accept daemon-sorted explicit outcomes without depending on request order
- cover mismatch and timeout cases in protocol unit tests

Diffstat:
Mcrates/transport_publish_protocol/src/lib.rs | 127++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 126 insertions(+), 1 deletion(-)

diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs @@ -45,6 +45,7 @@ pub enum TransportPublishProtocolError { InvalidPreviewBehavior { index: usize, }, + InvalidTimeoutMs, InvalidReticulumPreviewEndpoint { index: usize, }, @@ -79,6 +80,9 @@ pub enum TransportPublishProtocolError { InvalidJobDeliverySatisfiedState, InvalidJobCompletedAt, InvalidJobStatusState, + InvalidExplicitTargetOutcome { + index: usize, + }, InvalidTargetOutcomeKind { index: usize, }, @@ -118,6 +122,7 @@ impl fmt::Display for TransportPublishProtocolError { f, "transport target {index} preview_behavior is only valid for Reticulum targets" ), + Self::InvalidTimeoutMs => f.write_str("timeout_ms must be greater than zero"), Self::InvalidReticulumPreviewEndpoint { index } => write!( f, "transport target {index} Reticulum preview endpoint must be {RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI}" @@ -157,6 +162,10 @@ impl fmt::Display for TransportPublishProtocolError { f.write_str("job completed_at_ms does not match status or request time") } Self::InvalidJobStatusState => f.write_str("job status does not match target outcomes"), + Self::InvalidExplicitTargetOutcome { index } => write!( + f, + "transport target outcome {index} does not match explicit target policy" + ), Self::InvalidTargetOutcomeKind { index } => { write!( f, @@ -415,6 +424,9 @@ impl TransportPublishEventRequest { { return Err(TransportPublishProtocolError::EmptyIdempotencyKey); } + if self.timeout_ms == Some(0) { + return Err(TransportPublishProtocolError::InvalidTimeoutMs); + } Ok(()) } } @@ -595,6 +607,7 @@ impl TransportPublishJobView { for (index, target) in self.targets.iter().enumerate() { validate_target_outcome(target, index)?; } + validate_job_target_policy_outcomes(&self.target_policy, &self.targets)?; let has_outcomes = !self.targets.is_empty(); if has_outcomes || completed { if self.target_count != self.targets.len() { @@ -861,6 +874,42 @@ fn validate_target_outcome( Ok(()) } +fn validate_job_target_policy_outcomes( + target_policy: &TransportPublishTargetPolicy, + outcomes: &[TransportPublishTargetOutcome], +) -> Result<(), TransportPublishProtocolError> { + let TransportPublishTargetPolicy::ExplicitTargets { targets } = target_policy else { + return Ok(()); + }; + if outcomes.is_empty() { + return Ok(()); + } + if targets.len() != outcomes.len() { + return Err( + TransportPublishProtocolError::InvalidExplicitTargetOutcome { + index: outcomes.len().min(targets.len()), + }, + ); + } + let mut matched_targets = Vec::new(); + matched_targets.resize(targets.len(), false); + for (outcome_index, outcome) in outcomes.iter().enumerate() { + let Some((target_index, _)) = targets.iter().enumerate().find(|(target_index, target)| { + !matched_targets[*target_index] + && target.transport_kind == outcome.transport_kind + && target.endpoint_uri == outcome.endpoint_uri + }) else { + return Err( + TransportPublishProtocolError::InvalidExplicitTargetOutcome { + index: outcome_index, + }, + ); + }; + matched_targets[target_index] = true; + } + Ok(()) +} + fn validate_job_status_state( job: &TransportPublishJobView, acknowledged_count: usize, @@ -942,9 +991,16 @@ mod tests { } fn nostr_outcome(outcome_kind: TransportPublishOutcomeKind) -> TransportPublishTargetOutcome { + nostr_outcome_for("wss://relay.example.com", outcome_kind) + } + + fn nostr_outcome_for( + endpoint_uri: impl Into<String>, + outcome_kind: TransportPublishOutcomeKind, + ) -> TransportPublishTargetOutcome { TransportPublishTargetOutcome { transport_kind: "nostr".to_owned(), - endpoint_uri: "wss://relay.example.com".to_owned(), + endpoint_uri: endpoint_uri.into(), source: TransportPublishTargetSource::Request, attempted: true, outcome_kind, @@ -1211,6 +1267,13 @@ mod tests { empty_key.validate(1), Err(TransportPublishProtocolError::EmptyIdempotencyKey) ); + + let mut zero_timeout = request; + zero_timeout.timeout_ms = Some(0); + assert_eq!( + zero_timeout.validate(1), + Err(TransportPublishProtocolError::InvalidTimeoutMs) + ); } fn removed_proxy_kind_string() -> String { @@ -1282,6 +1345,60 @@ mod tests { } #[test] + fn job_view_validation_matches_explicit_target_outcomes_by_identity() { + job_from_targets( + TransportPublishJobStatus::DeliverySatisfied, + TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr("wss://relay-a.example.com"), + TransportPublishTarget::nostr("wss://relay-b.example.com"), + ]), + vec![ + nostr_outcome_for( + "wss://relay-b.example.com", + TransportPublishOutcomeKind::Accepted, + ), + nostr_outcome_for( + "wss://relay-a.example.com", + TransportPublishOutcomeKind::Accepted, + ), + ], + ) + .validate() + .expect("explicit target outcomes match regardless of order"); + + let mismatched_endpoint = job_from_targets( + TransportPublishJobStatus::DeliverySatisfied, + TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget::nostr( + "wss://relay-a.example.com", + )]), + vec![nostr_outcome_for( + "wss://relay-b.example.com", + TransportPublishOutcomeKind::Accepted, + )], + ); + assert_eq!( + mismatched_endpoint.validate(), + Err(TransportPublishProtocolError::InvalidExplicitTargetOutcome { index: 0 }) + ); + + let mismatched_count = job_from_targets( + TransportPublishJobStatus::DeliverySatisfied, + TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr("wss://relay-a.example.com"), + TransportPublishTarget::nostr("wss://relay-b.example.com"), + ]), + vec![nostr_outcome_for( + "wss://relay-a.example.com", + TransportPublishOutcomeKind::Accepted, + )], + ); + assert_eq!( + mismatched_count.validate(), + Err(TransportPublishProtocolError::InvalidExplicitTargetOutcome { index: 1 }) + ); + } + + #[test] fn job_view_validation_rejects_inconsistent_counts_flags_and_status() { let mut target_count_mismatch = accepted_job(); target_count_mismatch.target_count = 2; @@ -1469,6 +1586,10 @@ mod tests { "transport target 4 preview_behavior is only valid for Reticulum targets", ), ( + TransportPublishProtocolError::InvalidTimeoutMs, + "timeout_ms must be greater than zero", + ), + ( TransportPublishProtocolError::InvalidReticulumPreviewEndpoint { index: 5 }, "transport target 5 Reticulum preview endpoint must be reticulum:preview-unavailable", ), @@ -1492,6 +1613,10 @@ mod tests { TransportPublishProtocolError::EmptyJobId, "job id must not be empty", ), + ( + TransportPublishProtocolError::InvalidExplicitTargetOutcome { index: 6 }, + "transport target outcome 6 does not match explicit target policy", + ), ]; for (error, message) in cases {