commit 0455d7211ac01f0e8a6d15908285a90202bebc17
parent a97cf153fdfd4374945c0aa83aa7026d3be4e425
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Jul 2026 19:40:19 +0000
sync: harden radrootsd proxy target boundaries
- make proxy relay target conversion explicitly Nostr-only
- reject Reticulum outbox proxy targets before behavior can be rewritten
- preserve typed direct Reticulum proxy requests with explicit preview behavior
- add source-boundary and unit coverage for proxy behavior loss guards
Diffstat:
5 files changed, 159 insertions(+), 34 deletions(-)
diff --git a/crates/sdk/src/adapters/radrootsd.rs b/crates/sdk/src/adapters/radrootsd.rs
@@ -13,8 +13,8 @@ use radroots_transport_nostr::{
use radroots_transport_publish_protocol::{
METHOD_EVENT, SignedNostrEventWire, TransportPublishDeliveryPolicy,
TransportPublishEventRequest, TransportPublishEventResponse, TransportPublishOutcomeKind,
- TransportPublishPreviewBehavior, TransportPublishProtocolError, TransportPublishTarget,
- TransportPublishTargetOutcome, TransportPublishTargetPolicy,
+ TransportPublishProtocolError, TransportPublishTarget, TransportPublishTargetOutcome,
+ TransportPublishTargetPolicy,
};
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
@@ -127,7 +127,10 @@ impl RadrootsRelayPublishAdapter for RadrootsdProxyPublishAdapter {
)?,
signed_event: request.signed_event,
target_policy: TransportPublishTargetPolicy::explicit_targets(
- targets.iter().map(transport_publish_target).collect(),
+ targets
+ .iter()
+ .map(transport_publish_target)
+ .collect::<Result<Vec<_>, _>>()?,
),
idempotency_key: None,
timeout_ms: self.config.request_timeout_ms,
@@ -356,16 +359,20 @@ fn signed_event_wire(event: &RadrootsSignedNostrEvent) -> SignedNostrEventWire {
}
}
-fn transport_publish_target(target: &RadrootsTransportTarget) -> TransportPublishTarget {
- TransportPublishTarget {
+fn transport_publish_target(
+ target: &RadrootsTransportTarget,
+) -> Result<TransportPublishTarget, RadrootsRelayTransportError> {
+ if target.kind != RadrootsTransportKind::Nostr {
+ return Err(RadrootsRelayTransportError::Transport(format!(
+ "radrootsd proxy relay adapter is Nostr-only and cannot publish {} targets",
+ target.kind.canonical_label()
+ )));
+ }
+ Ok(TransportPublishTarget {
transport_kind: target.kind.canonical_label(),
endpoint_uri: target.uri.as_str().to_owned(),
- preview_behavior: if target.kind == RadrootsTransportKind::Reticulum {
- Some(TransportPublishPreviewBehavior::RejectDeliveryAttempts)
- } else {
- None
- },
- }
+ preview_behavior: None,
+ })
}
fn delivery_policy_from_relay_request(
diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs
@@ -762,7 +762,7 @@ async fn push_proxy_claimed_outbox_event(
now_ms,
)
.await?;
- let target_policy = proxy_transport_publish_target_policy(claimed);
+ let target_policy = proxy_transport_publish_target_policy(claimed)?;
let request = RadrootsdProxyPublishRequest {
signed_event: signed_event.clone(),
delivery_policy: proxy_delivery_policy(sync, claimed, &target_policy).await?,
@@ -899,42 +899,44 @@ async fn complete_proxy_publish_attempt(
#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))]
fn proxy_transport_publish_target_policy(
claimed: &RadrootsOutboxClaimedEvent,
-) -> TransportPublishTargetPolicy {
+) -> Result<TransportPublishTargetPolicy, RadrootsSdkError> {
let ready_targets = claimed
.delivery_targets
.iter()
.filter(|target| target.status.is_ready_for_attempt())
.collect::<Vec<_>>();
if ready_targets.len() == 1 && is_proxy_delegate_target(ready_targets[0]) {
- TransportPublishTargetPolicy::nostr(
+ Ok(TransportPublishTargetPolicy::nostr(
NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault,
Vec::new(),
- )
+ ))
} else {
- TransportPublishTargetPolicy::explicit_targets(
+ Ok(TransportPublishTargetPolicy::explicit_targets(
ready_targets
.into_iter()
.map(transport_publish_target_from_outbox_target)
- .collect(),
- )
+ .collect::<Result<Vec<_>, _>>()?,
+ ))
}
}
#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))]
fn transport_publish_target_from_outbox_target(
target: &RadrootsOutboxDeliveryTargetRecord,
-) -> TransportPublishTarget {
- TransportPublishTarget {
+) -> Result<TransportPublishTarget, RadrootsSdkError> {
+ if target.transport_kind == RadrootsTransportKind::Reticulum {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: format!(
+ "radrootsd proxy outbox publish does not accept Reticulum target {}",
+ target.endpoint_uri.as_str()
+ ),
+ });
+ }
+ Ok(TransportPublishTarget {
transport_kind: target.transport_kind.canonical_label(),
endpoint_uri: target.endpoint_uri.as_str().to_owned(),
- preview_behavior: if target.transport_kind == RadrootsTransportKind::Reticulum {
- Some(
- radroots_transport_publish_protocol::TransportPublishPreviewBehavior::RejectDeliveryAttempts,
- )
- } else {
- None
- },
- }
+ preview_behavior: None,
+ })
}
#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))]
diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs
@@ -1322,6 +1322,29 @@ fn sdk_proxy_surfaces_reject_removed_daemon_publish_proxy_identifiers() {
sync_runtime_source.contains("target.transport_kind == RadrootsTransportKind::Proxy"),
"src/sync_runtime.rs must identify proxy delegate targets with RadrootsTransportKind::Proxy"
);
+ assert!(
+ sync_runtime_source
+ .contains("radrootsd proxy outbox publish does not accept Reticulum target"),
+ "src/sync_runtime.rs must reject Reticulum proxy outbox targets before behavior is lost"
+ );
+ assert!(
+ !sync_runtime_source.contains("TransportPublishPreviewBehavior::RejectDeliveryAttempts"),
+ "src/sync_runtime.rs must not rewrite Reticulum proxy outbox targets to reject attempts"
+ );
+
+ let adapter_source = read_source(manifest_dir.join("src/adapters/radrootsd.rs").as_path());
+ assert!(
+ adapter_source.contains("target.kind != RadrootsTransportKind::Nostr"),
+ "src/adapters/radrootsd.rs must keep the relay proxy adapter Nostr-only"
+ );
+ assert!(
+ adapter_source.contains("radrootsd proxy relay adapter is Nostr-only"),
+ "src/adapters/radrootsd.rs must return a typed Nostr-only proxy adapter error"
+ );
+ assert!(
+ !adapter_source.contains("TransportPublishPreviewBehavior::RejectDeliveryAttempts"),
+ "src/adapters/radrootsd.rs must not rewrite Reticulum relay targets to reject attempts"
+ );
for required in [
"TransportPublishOutcomeKind::DeferredUntilImplemented",
diff --git a/crates/sdk/tests/unit/adapters_radrootsd_tests.rs b/crates/sdk/tests/unit/adapters_radrootsd_tests.rs
@@ -3,10 +3,11 @@ use radroots_transport_nostr::{
RadrootsRelayPublishRequest, RadrootsRelayTargetSet, RadrootsRelayUrlPolicy,
};
use radroots_transport_publish_protocol::{
- NostrPublishTargetSourcePolicy, TransportPublishDeliveryPolicy, TransportPublishEventRequest,
- TransportPublishEventResponse, TransportPublishJobStatus, TransportPublishJobView,
- TransportPublishOutcomeKind, TransportPublishTarget, TransportPublishTargetOutcome,
- TransportPublishTargetPolicy, TransportPublishTargetSource,
+ NostrPublishTargetSourcePolicy, RETICULUM_PREVIEW_ENDPOINT_URI, TransportPublishDeliveryPolicy,
+ TransportPublishEventRequest, TransportPublishEventResponse, TransportPublishJobStatus,
+ TransportPublishJobView, TransportPublishOutcomeKind, TransportPublishPreviewBehavior,
+ TransportPublishTarget, TransportPublishTargetOutcome, TransportPublishTargetPolicy,
+ TransportPublishTargetSource,
};
use std::io::{Read, Write};
use std::net::TcpListener;
@@ -488,6 +489,43 @@ async fn publish_signed_event_posts_typed_proxy_request() {
}
#[tokio::test]
+async fn publish_signed_event_preserves_typed_reticulum_preview_behavior() {
+ let (endpoint, handle) = spawn_http_server("200 OK", publish_response_json().as_str());
+ let adapter = RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new(endpoint));
+
+ adapter
+ .publish_signed_event(RadrootsdProxyPublishRequest {
+ signed_event: signed_event(),
+ target_policy: TransportPublishTargetPolicy::explicit_targets(vec![
+ TransportPublishTarget::reticulum_preview(
+ TransportPublishPreviewBehavior::DeferDeliveryPlans,
+ ),
+ ]),
+ delivery_policy: TransportPublishDeliveryPolicy::Any,
+ idempotency_key: Some("idem-reticulum".to_owned()),
+ timeout_ms: None,
+ })
+ .await
+ .expect("typed Reticulum publish request");
+
+ let recorded = handle.join().expect("server thread");
+ let body: serde_json::Value = serde_json::from_str(recorded.body.as_str()).expect("body");
+ assert_eq!(body["params"]["target_policy"]["kind"], "explicit_targets");
+ assert_eq!(
+ body["params"]["target_policy"]["targets"][0]["transport_kind"],
+ "reticulum"
+ );
+ assert_eq!(
+ body["params"]["target_policy"]["targets"][0]["endpoint_uri"],
+ RETICULUM_PREVIEW_ENDPOINT_URI
+ );
+ assert_eq!(
+ body["params"]["target_policy"]["targets"][0]["preview_behavior"],
+ "defer_delivery_plans"
+ );
+}
+
+#[tokio::test]
async fn publish_event_http_errors_omit_body_and_token_material() {
let body = "{\"error\":\"token-secret content carrots\"}";
let (endpoint, _handle) = spawn_http_server("503 Service Unavailable", body);
@@ -604,6 +642,23 @@ async fn relay_publish_adapter_maps_proxy_errors_to_transport_errors() {
));
}
+#[test]
+fn relay_proxy_target_conversion_rejects_reticulum_targets_before_behavior_loss() {
+ let target = radroots_transport::RadrootsTransportTarget::new(
+ radroots_transport::RadrootsTransportKind::Reticulum,
+ RETICULUM_PREVIEW_ENDPOINT_URI,
+ )
+ .expect("Reticulum target");
+
+ let error = transport_publish_target(&target).expect_err("Reticulum rejected");
+
+ assert!(matches!(
+ error,
+ radroots_transport_nostr::RadrootsRelayTransportError::Transport(message)
+ if message.contains("Nostr-only") && message.contains("reticulum")
+ ));
+}
+
#[tokio::test]
async fn publish_signed_event_rejects_invalid_protocol_requests_before_http() {
let adapter =
diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs
@@ -3,6 +3,7 @@ use super::{
CLAIM_OWNER, complete_proxy_publish_attempt, proxy_delivery_policy_from_satisfaction,
proxy_error_message, proxy_outbox_idempotency_key, proxy_transport_error_job,
push_proxy_claimed_outbox_event, push_proxy_event_receipt,
+ transport_publish_target_from_outbox_target,
};
use super::{
PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxTargetOutcomeKind,
@@ -36,10 +37,15 @@ use radroots_nostr::prelude::{
RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft,
};
#[cfg(feature = "radrootsd-proxy")]
-use radroots_outbox::{RadrootsOutboxClaimedEvent, RadrootsOutboxDeliveryTargetStatus};
+use radroots_outbox::{
+ RadrootsOutboxClaimedEvent, RadrootsOutboxDeliveryTargetRecord,
+ RadrootsOutboxDeliveryTargetStatus,
+};
use radroots_outbox::{RadrootsOutboxEventState, RadrootsOutboxStatusSummary};
#[cfg(feature = "radrootsd-proxy")]
-use radroots_transport::RadrootsTransportSatisfactionPolicy;
+use radroots_transport::{
+ RadrootsTransportKind, RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget,
+};
use radroots_transport_nostr::{
RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, RadrootsRelayPublishReceipt,
RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTransportError,
@@ -611,6 +617,38 @@ async fn proxy_push_empty_queue_and_private_helpers_are_deterministic() {
}
#[cfg(feature = "radrootsd-proxy")]
+#[test]
+fn proxy_outbox_target_conversion_rejects_reticulum_targets_before_behavior_loss() {
+ let target = RadrootsTransportTarget::new(
+ RadrootsTransportKind::Reticulum,
+ "reticulum:preview-unavailable",
+ )
+ .expect("Reticulum target");
+ let record = RadrootsOutboxDeliveryTargetRecord {
+ delivery_target_id: 1,
+ delivery_plan_id: 1,
+ transport_kind: target.kind.clone(),
+ endpoint_uri: target.uri.clone(),
+ endpoint_fingerprint: target.fingerprint.clone(),
+ status: RadrootsOutboxDeliveryTargetStatus::Pending,
+ attempt_count: 0,
+ last_attempt_at_ms: None,
+ completed_at_ms: None,
+ last_error: None,
+ };
+
+ let error =
+ transport_publish_target_from_outbox_target(&record).expect_err("Reticulum rejected");
+
+ assert!(matches!(
+ error,
+ RadrootsSdkError::InvalidRequest { message }
+ if message.contains("radrootsd proxy outbox publish")
+ && message.contains("Reticulum target")
+ ));
+}
+
+#[cfg(feature = "radrootsd-proxy")]
#[tokio::test]
async fn proxy_push_entrypoints_report_request_clock_and_claim_errors() {
let adapter =