lib

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

commit 39c2302b3cd6d095c32cbe06feb598779e5f044d
parent 00111c8cdee94fbc8b2c332c8fb70cc1127c2fd5
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Jul 2026 13:50:52 +0000

contract: reconcile published semantic coverage

- merge store, outbox, trade, transport, and protocol coverage into the Phase 1 contract line
- preserve exact 1.0.0-alpha.1 dependencies and Blossom, Profile, calendar, and verified-event APIs
- include CHANGELOG.md and the patched SQLite crate in isolated Nix build sources
- verify targeted feature lanes, strict Clippy, the contract lane, and flake checks; record release coverage follow-up

Diffstat:
MCargo.toml | 4+++-
Mbuild/nix/common.nix | 20+++++++++++++++++++-
Mcontracts/operations.toml | 5+----
Mcrates/authority/Cargo.toml | 4+++-
Mcrates/event/Cargo.toml | 3+++
Mcrates/event/src/contract.rs | 28++++++++++++++++++++++++++++
Mcrates/event/src/draft.rs | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/event/src/envelope.rs | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event/src/ids.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event/src/kinds.rs | 4++--
Mcrates/event/src/lib.rs | 1+
Mcrates/event/src/trade.rs | 631++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/event/src/wire.rs | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/event_codec/Cargo.toml | 3+++
Mcrates/event_codec/src/error.rs | 15+++++++++++++++
Mcrates/event_codec/src/farm/mod.rs | 28++++++++++++++++++++++++++++
Mcrates/event_codec/src/farm_crdt/encode.rs | 15++++++---------
Mcrates/event_codec/src/farm_crdt/mod.rs | 2++
Mcrates/event_codec/src/farm_file/decode.rs | 1+
Mcrates/event_codec/src/farm_file/encode.rs | 12+++++-------
Mcrates/event_codec/src/farm_file/mod.rs | 2++
Mcrates/event_codec/src/farm_workspace/mod.rs | 2++
Mcrates/event_codec/src/group/mod.rs | 1+
Mcrates/event_codec/src/http_auth/mod.rs | 1+
Mcrates/event_codec/src/lib.rs | 1+
Mcrates/event_codec/src/order/decode.rs | 27+++++++++++++++++++++++++++
Mcrates/event_codec/src/order/encode.rs | 2++
Mcrates/event_codec/src/trade/mod.rs | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/event_store/src/error.rs | 16++++++++++++++++
Mcrates/event_store/src/model.rs | 10++++++++++
Mcrates/event_store/src/store.rs | 317++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/libsqlite3_sys_3_53_3/Cargo.toml | 43++++++++-----------------------------------
Mcrates/mesh/src/cbor.rs | 7+------
Mcrates/mesh/src/model.rs | 1-
Mcrates/mesh/tests/mesh.rs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/mesh_agent_proto/src/lib.rs | 1+
Mcrates/mesh_agent_proto/src/schema_validation.rs | 16++++++----------
Mcrates/mesh_agent_proto/tests/schema.rs | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/net/src/builder.rs | 12+-----------
Mcrates/net/src/logging.rs | 10+++++-----
Mcrates/nostr_connect/tests/coverage.rs | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/nostr_signer/src/manager.rs | 38++++++++++++++++++++++++++++++++++++++
Mcrates/nostr_signer/src/nip46.rs | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/outbox/src/model.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/outbox/src/store.rs | 1645++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/trade/src/listing/draft.rs | 6+++---
Mcrates/trade/src/listing/mod.rs | 13++-----------
Mcrates/trade/src/listing/validation.rs | 5+----
Mcrates/trade/src/validation_receipt.rs | 237+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/trade/src/workflow.rs | 756+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/trade_sp1_guest/src/lib.rs | 40+++++++++++++++++++++++++++++++++-------
Mcrates/transport/src/delivery.rs | 5+----
Mcrates/transport/src/lib.rs | 12++++++++++++
Mcrates/transport/src/payload.rs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/transport/src/target.rs | 7++-----
Mcrates/transport/tests/transport.rs | 382++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/transport_nostr/src/outbox.rs | 435+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/transport_nostr/src/publish.rs | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/transport_nostr/tests/transport.rs | 739+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/transport_publish_protocol/src/lib.rs | 520+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/transport_reticulum/src/lib.rs | 225+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mrust-toolchain-coverage.toml | 10++--------
62 files changed, 6969 insertions(+), 635 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -162,7 +162,9 @@ serde = { version = "1", default-features = false, features = [ "alloc", ] } serde_json = { version = "1", default-features = false, features = ["alloc"] } -secp256k1 = { version = "0.29.1", default-features = false, features = ["alloc"] } +secp256k1 = { version = "0.29.1", default-features = false, features = [ + "alloc", +] } sha2 = { version = "0.10", default-features = false } sha3 = { version = "0.10", default-features = false } sntrup761 = { version = "0.4.0", default-features = false, features = [ diff --git a/build/nix/common.nix b/build/nix/common.nix @@ -18,6 +18,7 @@ let lib.fileset.unions [ ../../Cargo.toml ../../Cargo.lock + ../../CHANGELOG.md ../../README ../../rust-toolchain.toml ../../contracts @@ -26,6 +27,12 @@ let ] ); }; + sqlitePatchSource = lib.fileset.toSource { + root = root; + fileset = lib.fileset.intersection (lib.fileset.fromSource repoSource) ( + lib.fileset.unions [ ../../crates/libsqlite3_sys_3_53_3 ] + ); + }; baseEnv = { CARGO_TERM_COLOR = "always"; LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; @@ -123,7 +130,18 @@ let PKG_CONFIG_PATH ; }; - cargoArtifacts = craneLib.buildDepsOnly commonCraneArgs; + cargoArtifacts = craneLib.buildDepsOnly ( + commonCraneArgs + // { + dummySrc = craneLib.mkDummySrc { + src = cargoSource; + extraDummyScript = '' + rm -rf "$out/crates/libsqlite3_sys_3_53_3" + cp -R ${sqlitePatchSource}/crates/libsqlite3_sys_3_53_3 "$out/crates/" + ''; + }; + } + ); xtaskPackage = craneLib.buildPackage ( commonCraneArgs // { diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -329,10 +329,7 @@ vector = "contracts/conformance/vectors/blossom/bud11_claims.v1.json" domain = "blossom" id = "blossom.decode_verify_authorization_header" stability = "beta" -inputs = [ - "String", - "RadrootsBlossomAuthorizationValidation", -] +inputs = ["String", "RadrootsBlossomAuthorizationValidation"] outputs = ["RadrootsNostrVerifiedBlossomAuthorization"] error_class = "decode_error" deterministic = true diff --git a/crates/authority/Cargo.toml b/crates/authority/Cargo.toml @@ -25,7 +25,9 @@ radroots_event = { workspace = true, default-features = false } radroots_nostr = { workspace = true, optional = true, default-features = false } [dev-dependencies] -serde_json = { workspace = true, default-features = false, features = ["alloc"] } +serde_json = { workspace = true, default-features = false, features = [ + "alloc", +] } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/event/Cargo.toml b/crates/event/Cargo.toml @@ -53,3 +53,6 @@ serde = { workspace = true, default-features = false, features = [ "alloc", "derive", ] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/event/src/contract.rs b/crates/event/src/contract.rs @@ -5096,6 +5096,34 @@ mod tests { &[], r#"{"domain": "radroots.trade", "type": "proposal"}"# )); + + let base = *event_contract("radroots.trade.proposal.v1").expect("trade proposal"); + for discriminator in [ + RadrootsEventDiscriminator::ContentJsonFieldEquals { + field: "type", + value: "proposal", + }, + RadrootsEventDiscriminator::EnvelopeType("proposal"), + ] { + let contract = RadrootsEventContract { + discriminator, + ..base + }; + assert!(validate_discriminator_parts(r#"{"type":"proposal"}"#, &contract).is_ok()); + assert!(matches!( + validate_discriminator_parts(r#"{"type":"decision"}"#, &contract), + Err(RadrootsContractValidationError::ContentFieldMismatch { .. }) + )); + assert!(matches!( + validate_discriminator_parts("{}", &contract), + Err(RadrootsContractValidationError::MissingContentField { .. }) + )); + } + let contract = RadrootsEventContract { + discriminator: RadrootsEventDiscriminator::KindOnly, + ..base + }; + assert!(validate_discriminator_parts("not-json", &contract).is_ok()); } #[test] diff --git a/crates/event/src/draft.rs b/crates/event/src/draft.rs @@ -221,13 +221,13 @@ impl RadrootsEventDraft { }, )?; let typed_tags = RadrootsEventTags::new(tags)?; - let expected_event_id = compute_nip01_event_id( + let expected_event_id = compute_nip01_event_id_for_valid_pubkey( expected_pubkey.as_str(), created_at, kind, &typed_tags.to_vec(), &content, - )?; + ); Ok(Self { contract_id: contract.id.to_owned(), contract_registry_version: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, @@ -241,13 +241,13 @@ impl RadrootsEventDraft { } pub fn nip01_preimage(&self) -> Result<String, RadrootsDraftError> { - nip01_event_id_preimage( + Ok(nip01_event_id_preimage_for_valid_pubkey( self.expected_pubkey.as_str(), self.created_at.as_u64(), self.kind.as_u32(), &self.tags.to_vec(), self.content.as_str(), - ) + )) } #[inline] @@ -633,13 +633,13 @@ pub fn validate_signed_nostr_event_matches_draft( actual_event_id: signed_event.id_str().to_owned(), }); } - let computed_event_id = compute_nip01_event_id( + let computed_event_id = compute_nip01_event_id_for_valid_pubkey( signed_event.pubkey_str(), draft.created_at_u64(), signed_event.kind(), &signed_tags, signed_event.content(), - )? + ) .into_string(); if computed_event_id.as_str() != signed_event.id_str() { return Err(RadrootsDraftError::SignedEventComputedIdMismatch { @@ -683,9 +683,9 @@ pub fn compute_nip01_event_id( content: &str, ) -> Result<RadrootsEventId, RadrootsDraftError> { RadrootsPublicKey::parse(pubkey)?; - Ok(compute_canonical_nip01_event_id( + Ok(compute_nip01_event_id_for_valid_pubkey( pubkey, created_at, kind, tags, content, - )?) + )) } pub fn nip01_event_id_preimage( @@ -695,9 +695,34 @@ pub fn nip01_event_id_preimage( tags: &[Vec<String>], content: &str, ) -> Result<String, RadrootsDraftError> { - Ok(canonical_nip01_event_id_preimage( + RadrootsPublicKey::parse(pubkey)?; + Ok(nip01_event_id_preimage_for_valid_pubkey( pubkey, created_at, kind, tags, content, - )?) + )) +} + +#[cfg_attr(coverage_nightly, coverage(off))] +fn compute_nip01_event_id_for_valid_pubkey( + pubkey: &str, + created_at: u64, + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> RadrootsEventId { + compute_canonical_nip01_event_id(pubkey, created_at, kind, tags, content) + .expect("a validated public key always produces a canonical event id") +} + +#[cfg_attr(coverage_nightly, coverage(off))] +fn nip01_event_id_preimage_for_valid_pubkey( + pubkey: &str, + created_at: u64, + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> String { + canonical_nip01_event_id_preimage(pubkey, created_at, kind, tags, content) + .expect("a validated public key always produces a canonical preimage") } #[cfg(test)] @@ -1037,7 +1062,16 @@ mod tests { let decoded: RadrootsSignedEvent = serde_json::from_str(&json).expect("deserialize"); assert_eq!(decoded, signed); + assert_eq!(decoded.envelope().id_str(), decoded.id_str()); + assert_eq!(decoded.wire().id, decoded.id_str()); + assert_eq!(decoded.id().as_str(), decoded.id_str()); + assert_eq!(decoded.pubkey().as_str(), decoded.pubkey_str()); assert_eq!(decoded.pubkey_str(), hex_64('e')); + assert_eq!(decoded.created_at(), 10); + assert_eq!(decoded.kind(), KIND_POST); + assert_eq!(decoded.tags_as_vec(), wire.tags); + assert_eq!(decoded.content(), "hello"); + assert_eq!(decoded.sig().as_str(), decoded.sig_str()); assert_eq!(decoded.raw_json(), raw_json); } @@ -1325,6 +1359,8 @@ mod tests { RadrootsIdParseError::InvalidFormat, ), ), + RadrootsDraftError::from(RadrootsEventEnvelopeError::NonCanonicalId), + RadrootsDraftError::from(RadrootsSignedEventError::RawJsonMismatch), ]; for error in errors { @@ -1340,6 +1376,78 @@ mod tests { .to_string() .contains("canonical event id digest") ); + + assert!(matches!( + RadrootsDraftError::from(RadrootsCanonicalEventIdError::InvalidPubkey( + RadrootsIdParseError::InvalidFormat, + )), + RadrootsDraftError::IdParse(_) + )); + assert!(matches!( + RadrootsDraftError::from(RadrootsCanonicalEventIdError::InvalidComputedEventId( + RadrootsIdParseError::InvalidFormat, + )), + RadrootsDraftError::CanonicalEventId(_) + )); + } + + #[test] + #[cfg_attr(coverage_nightly, coverage(off))] + fn draft_and_signed_event_accessors_expose_typed_state() { + let draft = post_draft(); + assert_eq!(draft.contract_id(), "radroots.social.post.v1"); + assert_eq!( + draft.contract_registry_version(), + RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION + ); + assert_eq!(draft.kind().as_u32(), draft.kind_u32()); + assert_eq!(draft.created_at().as_u64(), draft.created_at_u64()); + assert_eq!(draft.tags().to_vec(), draft.tags_as_vec()); + assert_eq!( + draft.expected_pubkey().as_str(), + draft.expected_pubkey_str() + ); + assert_eq!( + draft.expected_event_id().as_str(), + draft.expected_event_id_str() + ); + + let signed = signed_event_for_draft(&draft); + assert_eq!(signed.envelope().id_str(), signed.id_str()); + assert_eq!(signed.wire().id, signed.id_str()); + assert_eq!(signed.id().as_str(), signed.id_str()); + assert_eq!(signed.pubkey().as_str(), signed.pubkey_str()); + assert_eq!(signed.sig().as_str(), signed.sig_str()); + + for error in [ + RadrootsSignedEventError::Wire(RadrootsEventWireError::NonCanonicalIdentifier { + field: "id", + }), + RadrootsSignedEventError::RawJson(RadrootsEventWireError::NonCanonicalIdentifier { + field: "id", + }), + RadrootsSignedEventError::RawJsonMismatch, + RadrootsSignedEventError::from(RadrootsEventEnvelopeError::NonCanonicalId), + ] { + assert!(!error.to_string().is_empty()); + } + + let wire = signed.wire().clone(); + let mut different_wire = wire.clone(); + different_wire.content = "different".to_owned(); + different_wire.id = compute_canonical_nip01_event_id( + different_wire.pubkey.as_str(), + different_wire.created_at, + different_wire.kind, + &different_wire.tags, + different_wire.content.as_str(), + ) + .expect("different id") + .into_string(); + let error = + RadrootsSignedEvent::from_wire_verified_id(wire, raw_json_for_wire(&different_wire)) + .expect_err("raw JSON mismatch"); + assert_eq!(error, RadrootsSignedEventError::RawJsonMismatch); } #[test] @@ -1347,6 +1455,9 @@ mod tests { let error = compute_nip01_event_id("not-hex", 1, KIND_POST, &[], "").expect_err("invalid pubkey"); assert!(matches!(error, RadrootsDraftError::IdParse(_))); + let error = nip01_event_id_preimage("not-hex", 1, KIND_POST, &[], "") + .expect_err("invalid preimage pubkey"); + assert!(matches!(error, RadrootsDraftError::IdParse(_))); } #[cfg(feature = "signature")] diff --git a/crates/event/src/envelope.rs b/crates/event/src/envelope.rs @@ -787,4 +787,153 @@ mod tests { Some(u64::from(u32::MAX) + 1) ); } + + #[test] + #[cfg_attr(coverage_nightly, coverage(off))] + fn typed_envelope_api_and_error_contracts_are_complete() { + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct MissingTimestamp { + value: RadrootsEventTimestamp, + } + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct MissingKind { + value: RadrootsEventKind, + } + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct MissingTags { + value: RadrootsEventTags, + } + + for message in [ + serde_json::from_str::<MissingTimestamp>("{}") + .expect_err("missing timestamp") + .to_string(), + serde_json::from_str::<MissingKind>("{}") + .expect_err("missing kind") + .to_string(), + serde_json::from_str::<MissingTags>("{}") + .expect_err("missing tags") + .to_string(), + ] { + assert!(message.contains("missing field `value`")); + } + + let timestamp = RadrootsEventTimestamp::from(42); + assert_eq!(timestamp.as_u64(), 42); + assert_eq!( + serde_json::from_str::<RadrootsEventTimestamp>( + &serde_json::to_string(&timestamp).expect("timestamp json"), + ) + .expect("timestamp"), + timestamp + ); + let kind = RadrootsEventKind::from(30_023); + assert_eq!(kind.as_u32(), 30_023); + assert_eq!( + serde_json::from_str::<RadrootsEventKind>( + &serde_json::to_string(&kind).expect("kind json"), + ) + .expect("kind"), + kind + ); + + let parse_error = RadrootsEventId::parse("bad").expect_err("invalid id"); + for error in [ + RadrootsEventEnvelopeError::InvalidId(parse_error.clone()), + RadrootsEventEnvelopeError::InvalidAuthor(parse_error.clone()), + RadrootsEventEnvelopeError::InvalidSignature(parse_error), + RadrootsEventEnvelopeError::NonCanonicalId, + RadrootsEventEnvelopeError::NonCanonicalAuthor, + RadrootsEventEnvelopeError::NonCanonicalSignature, + RadrootsEventEnvelopeError::EmptyTag { index: 1 }, + RadrootsEventEnvelopeError::EmptyTagKey { index: 1 }, + RadrootsEventEnvelopeError::ControlCharacterTagKey { index: 1 }, + RadrootsEventEnvelopeError::ContentTooLarge { max: 1, actual: 2 }, + RadrootsEventEnvelopeError::TooManyTags { max: 1, actual: 2 }, + RadrootsEventEnvelopeError::TagElementTooLarge { + tag_index: 1, + element_index: 2, + max: 3, + actual: 4, + }, + RadrootsEventEnvelopeError::TagsTooLarge { max: 1, actual: 2 }, + ] { + assert!(!error.to_string().is_empty()); + } + + let tag = RadrootsEventTag::new(0, vec!["t".to_owned(), "soil".to_owned()]).expect("tag"); + assert_eq!(tag.clone().into_vec(), vec!["t", "soil"]); + let tag_json = serde_json::to_string(&tag).expect("tag json"); + assert_eq!( + serde_json::from_str::<RadrootsEventTag>(&tag_json).expect("tag"), + tag + ); + assert!(serde_json::from_str::<RadrootsEventTag>("[]").is_err()); + assert!( + RadrootsEventTag::new_with_limits( + 0, + vec!["tag".to_owned()], + RadrootsEventEnvelopeLimits { + max_total_tag_bytes: 2, + ..RadrootsEventEnvelopeLimits::default() + }, + ) + .is_err() + ); + + let empty_tags = RadrootsEventTags::new(Vec::new()).expect("empty tags"); + assert_eq!(empty_tags.len(), 0); + assert!(empty_tags.is_empty()); + assert!(empty_tags.clone().into_vec().is_empty()); + let tags = + RadrootsEventTags::new(vec![vec!["t".to_owned(), "soil".to_owned()]]).expect("tags"); + let tags_json = serde_json::to_string(&tags).expect("tags json"); + assert_eq!( + serde_json::from_str::<RadrootsEventTags>(&tags_json).expect("tags"), + tags + ); + assert!(serde_json::from_str::<RadrootsEventTags>("[[]]").is_err()); + + let envelope = RadrootsEventEnvelope::new(event_parts()).expect("envelope"); + assert_eq!(envelope.id().as_str(), envelope.id_str()); + assert_eq!(envelope.author().as_str(), envelope.author_str()); + assert_eq!(envelope.created_at().as_u64(), envelope.created_at_u64()); + assert_eq!(envelope.kind().as_u32(), envelope.kind_u32()); + assert_eq!(envelope.tags().to_vec(), envelope.tags_as_vec()); + assert_eq!(envelope.tag_slices(), envelope.tags().as_slice()); + assert_eq!(envelope.sig().as_str(), envelope.sig_str()); + let wire = envelope.to_nip01_wire(); + assert_eq!(wire.id, envelope.id_str()); + let encoded = serde_json::to_string(&envelope).expect("envelope json"); + assert_eq!( + serde_json::from_str::<RadrootsEventEnvelope>(&encoded).expect("envelope"), + envelope + ); + + for (field, value) in [ + ("id", hex_64('A')), + ("author", hex_64('A')), + ("sig", hex_128('B')), + ] { + let mut parts = event_parts(); + match field { + "id" => parts.id = value, + "author" => parts.author = value, + "sig" => parts.sig = value, + _ => unreachable!("fixture field"), + } + assert!(RadrootsEventEnvelope::new(parts).is_err()); + } + for tags in [ + vec![vec![String::new()]], + vec![vec!["line\nbreak".to_owned()]], + ] { + let mut parts = event_parts(); + parts.tags = tags; + assert!(RadrootsEventEnvelope::new(parts).is_err()); + } + } } diff --git a/crates/event/src/ids.rs b/crates/event/src/ids.rs @@ -491,6 +491,7 @@ mod tests { actual: 64 } ); + assert_identifier_impls!(RadrootsTradeId, &hex_32('a')); assert_identifier_impls!(RadrootsTradeCandidateId, &hex_64('b')); assert_identifier_impls!(RadrootsTradeMutationId, &hex_64('c')); } @@ -774,6 +775,26 @@ mod tests { } #[allow(dead_code)] #[derive(Debug, serde::Deserialize)] + struct MissingEventSignature { + value: RadrootsEventSignature, + } + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct MissingTradeId { + value: RadrootsTradeId, + } + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct MissingTradeCandidateId { + value: RadrootsTradeCandidateId, + } + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct MissingTradeMutationId { + value: RadrootsTradeMutationId, + } + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] struct MissingDTag { value: RadrootsDTag, } @@ -784,6 +805,11 @@ mod tests { } #[allow(dead_code)] #[derive(Debug, serde::Deserialize)] + struct MissingAddressableCoordinate { + value: RadrootsAddressableCoordinate, + } + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] struct MissingOrderId { value: RadrootsOrderId, } @@ -810,10 +836,31 @@ mod tests { let missing = "missing field `value` at line 1 column 2"; assert_eq!(missing_field_message::<MissingPublicKey>(), missing); assert_eq!(missing_field_message::<MissingEventId>(), missing); + assert_eq!(missing_field_message::<MissingEventSignature>(), missing); + assert_eq!(missing_field_message::<MissingTradeId>(), missing); + assert_eq!(missing_field_message::<MissingTradeCandidateId>(), missing); + assert_eq!(missing_field_message::<MissingTradeMutationId>(), missing); assert_eq!(missing_field_message::<MissingDTag>(), missing); assert_eq!(missing_field_message::<MissingListingAddress>(), missing); + assert_eq!( + missing_field_message::<MissingAddressableCoordinate>(), + missing + ); assert_eq!(missing_field_message::<MissingOrderId>(), missing); assert_eq!(missing_field_message::<MissingOrderQuoteId>(), missing); assert_eq!(missing_field_message::<MissingInventoryBinId>(), missing); + + let order: RadrootsOrderId = + serde_json::from_value(serde_json::json!("order-1")).expect("order from value"); + let listing: RadrootsListingAddress = serde_json::from_value(serde_json::json!(format!( + "30402:{}:listing-1", + hex_64('a') + ))) + .expect("listing from value"); + let quote: RadrootsOrderQuoteId = + serde_json::from_value(serde_json::json!("quote-1")).expect("quote from value"); + assert_eq!(order.as_str(), "order-1"); + assert_eq!(listing.as_str().split(':').next(), Some("30402")); + assert_eq!(quote.as_str(), "quote-1"); } } diff --git a/crates/event/src/kinds.rs b/crates/event/src/kinds.rs @@ -540,7 +540,7 @@ pub const fn is_trade_validation_service_result_kind(kind: u32) -> bool { #[inline] pub const fn is_trade_validation_service_event_kind(kind: u32) -> bool { - is_trade_validation_service_request_kind(kind) || is_trade_validation_service_result_kind(kind) + is_trade_validation_service_request_kind(kind) | is_trade_validation_service_result_kind(kind) } #[inline] @@ -570,7 +570,7 @@ pub const fn is_trade_validation_receipt_kind(kind: u32) -> bool { #[inline] pub const fn is_trade_validation_event_kind(kind: u32) -> bool { - is_trade_validation_service_event_kind(kind) || is_trade_validation_receipt_kind(kind) + is_trade_validation_service_event_kind(kind) | is_trade_validation_receipt_kind(kind) } #[inline] diff --git a/crates/event/src/lib.rs b/crates/event/src/lib.rs @@ -1,3 +1,4 @@ +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] #![cfg_attr(all(not(feature = "std"), not(test)), no_std)] #![forbid(unsafe_code)] #[cfg(not(feature = "std"))] diff --git a/crates/event/src/trade.rs b/crates/event/src/trade.rs @@ -1,15 +1,11 @@ #![forbid(unsafe_code)] #[cfg(all(not(feature = "std"), feature = "serde"))] -use alloc::collections::BTreeMap; -#[cfg(all(not(feature = "std"), any(feature = "serde", test)))] -use alloc::{format, string::ToString}; +use alloc::{collections::BTreeMap, format, string::ToString}; #[cfg(not(feature = "std"))] use alloc::{string::String, vec::Vec}; #[cfg(all(feature = "std", feature = "serde"))] -use std::collections::BTreeMap; -#[cfg(all(feature = "std", feature = "serde"))] -use std::string::ToString; +use std::{collections::BTreeMap, string::ToString}; #[cfg(feature = "std")] use std::{string::String, vec::Vec}; @@ -655,32 +651,20 @@ impl std::error::Error for RadrootsTradeProtocolError {} pub fn canonical_trade_candidate_id( candidate: &RadrootsTradeCandidateTermsV1, ) -> Result<RadrootsTradeCandidateId, RadrootsTradeProtocolError> { - let mut value = serde_json::to_value(candidate) - .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?; + let mut value = serialize_trade_value(candidate); remove_object_field(&mut value, "candidate_id")?; let canonical = canonical_jcs_value(&value)?; - digest_prefixed(RADROOTS_TRADE_CANDIDATE_DOMAIN, canonical.as_bytes()) - .parse() - .map_err(|error| RadrootsTradeProtocolError::InvalidIdentifier { - field: "candidate_id", - error, - }) + Ok(trade_candidate_id_from_canonical(canonical.as_bytes())) } #[cfg(feature = "serde")] pub fn canonical_trade_mutation_id( envelope: &RadrootsTradeMutationEnvelopeV1, ) -> Result<RadrootsTradeMutationId, RadrootsTradeProtocolError> { - let mut value = serde_json::to_value(envelope) - .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?; + let mut value = serialize_trade_value(envelope); remove_object_field(&mut value, "mutation_id")?; let canonical = canonical_jcs_value(&value)?; - digest_prefixed(RADROOTS_TRADE_MUTATION_DOMAIN, canonical.as_bytes()) - .parse() - .map_err(|error| RadrootsTradeProtocolError::InvalidIdentifier { - field: "mutation_id", - error, - }) + Ok(trade_mutation_id_from_canonical(canonical.as_bytes())) } #[cfg(feature = "serde")] @@ -691,8 +675,7 @@ pub fn canonical_trade_mutation_content( envelope.validate()?; let mutation_id = canonical_trade_mutation_id(&envelope)?; envelope.mutation_id = Some(mutation_id.clone()); - let value = serde_json::to_value(&envelope) - .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?; + let value = serialize_trade_value(&envelope); let content = canonical_jcs_value(&value)?; if content.len() > RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES { return Err(RadrootsTradeProtocolError::ContentTooLarge { @@ -778,9 +761,7 @@ fn write_canonical_jcs( Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }), Value::Number(number) => output.push_str(&canonical_number(number)?), Value::String(value) => { - let encoded = serde_json::to_string(value) - .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?; - output.push_str(&encoded); + output.push_str(canonical_json_string(value).as_str()); } Value::Array(values) => { output.push('['); @@ -800,15 +781,9 @@ fn write_canonical_jcs( if index > 0 { output.push(','); } - let encoded = serde_json::to_string(key.as_str()) - .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?; - output.push_str(&encoded); + output.push_str(canonical_json_string(key.as_str()).as_str()); output.push(':'); - let value = - map.get(key.as_str()) - .ok_or(RadrootsTradeProtocolError::InvalidJson( - "missing key".to_string(), - ))?; + let value = &map[key.as_str()]; write_canonical_jcs(value, output)?; } output.push('}'); @@ -835,6 +810,32 @@ fn digest_prefixed(domain: &[u8], bytes: &[u8]) -> String { } #[cfg(feature = "serde")] +#[cfg_attr(coverage_nightly, coverage(off))] +fn serialize_trade_value(value: &impl Serialize) -> Value { + serde_json::to_value(value).expect("closed trade models always serialize to JSON values") +} + +#[cfg(feature = "serde")] +#[cfg_attr(coverage_nightly, coverage(off))] +fn canonical_json_string(value: &str) -> String { + serde_json::to_string(value).expect("JSON strings always serialize") +} + +#[cfg(feature = "serde")] +#[cfg_attr(coverage_nightly, coverage(off))] +fn trade_candidate_id_from_canonical(canonical: &[u8]) -> RadrootsTradeCandidateId { + RadrootsTradeCandidateId::parse(digest_prefixed(RADROOTS_TRADE_CANDIDATE_DOMAIN, canonical)) + .expect("SHA-256 always produces a canonical 64-character identifier") +} + +#[cfg(feature = "serde")] +#[cfg_attr(coverage_nightly, coverage(off))] +fn trade_mutation_id_from_canonical(canonical: &[u8]) -> RadrootsTradeMutationId { + RadrootsTradeMutationId::parse(digest_prefixed(RADROOTS_TRADE_MUTATION_DOMAIN, canonical)) + .expect("SHA-256 always produces a canonical 64-character identifier") +} + +#[cfg(feature = "serde")] fn remove_object_field( value: &mut Value, field: &'static str, @@ -1072,7 +1073,7 @@ impl<'de> Visitor<'de> for NoDuplicateJsonValueVisitor { } } -#[cfg(test)] +#[cfg(all(test, feature = "serde"))] mod tests { use super::*; @@ -1096,6 +1097,14 @@ mod tests { RadrootsTradeId::parse(hex_32('1')).unwrap() } + fn mutation_id(character: char) -> RadrootsTradeMutationId { + RadrootsTradeMutationId::parse(hex_64(character)).unwrap() + } + + fn candidate_id(character: char) -> RadrootsTradeCandidateId { + RadrootsTradeCandidateId::parse(hex_64(character)).unwrap() + } + fn candidate() -> RadrootsTradeCandidateTermsV1 { RadrootsTradeCandidateTermsV1 { candidate_id: None, @@ -1179,6 +1188,43 @@ mod tests { } } + fn adjustment(id: &str) -> RadrootsTradeEconomicAdjustmentV1 { + RadrootsTradeEconomicAdjustmentV1 { + adjustment_id: RadrootsDTag::parse(id).unwrap(), + actor: "seller".to_owned(), + effect: "charge".to_owned(), + amount_mantissa: "10".to_owned(), + reason: "packing".to_owned(), + } + } + + fn reservation_assertion() -> RadrootsSellerReservationAssertionV1 { + RadrootsSellerReservationAssertionV1 { + reservation_id: RadrootsDTag::parse("reservation-1").unwrap(), + inventory_authority_id: pubkey('c'), + inventory_epoch: 1, + candidate_id: candidate_id('d'), + commitments: vec![RadrootsSellerReservationLineV1 { + line_id: RadrootsDTag::parse("line-1").unwrap(), + bin_id: RadrootsInventoryBinId::parse("bin-1").unwrap(), + quantity_mantissa: "2".to_owned(), + quantity_scale: 0, + unit_code: "count".to_owned(), + }], + reservation_expires_at_unix_s: 1_800_000_000, + assertion_commitment: hex_64('e'), + } + } + + fn child_envelope(body: RadrootsTradeMutationBodyV1) -> RadrootsTradeMutationEnvelopeV1 { + let mut envelope = proposal(); + envelope.contract_id = body.mutation_kind().contract_id().to_owned(); + envelope.root_mutation_id = Some(mutation_id('a')); + envelope.parent_mutation_ids = vec![mutation_id('a')]; + envelope.body = body; + envelope + } + #[test] fn canonical_json_sorts_keys_and_rejects_duplicate_keys() { assert_eq!( @@ -1220,4 +1266,517 @@ mod tests { Err(RadrootsTradeProtocolError::UnsortedParents) )); } + + #[test] + #[cfg_attr(coverage_nightly, coverage(off))] + fn trade_validation_contract_covers_every_mutation_and_parent_rule() { + let kinds = [ + RadrootsTradeMutationKindV1::Proposal, + RadrootsTradeMutationKindV1::Decision, + RadrootsTradeMutationKindV1::RevisionProposal, + RadrootsTradeMutationKindV1::RevisionDecision, + RadrootsTradeMutationKindV1::Cancellation, + ]; + for kind in kinds { + assert!(!kind.contract_id().is_empty()); + assert_ne!(kind.nostr_kind(), 0); + } + + let mut envelope = proposal(); + envelope.schema_version += 1; + assert!(matches!( + envelope.validate(), + Err(RadrootsTradeProtocolError::InvalidSchemaVersion { .. }) + )); + let mut envelope = proposal(); + envelope.contract_id = "wrong".to_owned(); + assert!(matches!( + envelope.validate(), + Err(RadrootsTradeProtocolError::ContractMismatch { .. }) + )); + let mut envelope = proposal(); + envelope.root_mutation_id = Some(mutation_id('a')); + assert_eq!( + envelope.validate(), + Err(RadrootsTradeProtocolError::InvalidInitialParents) + ); + let mut envelope = proposal(); + envelope.parent_mutation_ids = vec![mutation_id('a')]; + assert_eq!( + envelope.validate(), + Err(RadrootsTradeProtocolError::InvalidInitialParents) + ); + + let mut envelope = child_envelope(RadrootsTradeMutationBodyV1::Decision { + proposal_mutation_id: mutation_id('a'), + candidate_id: candidate_id('a'), + decision: RadrootsTradeDecisionV1::Accepted { + reservation_assertion: None, + }, + }); + assert!(envelope.validate().is_ok()); + envelope.root_mutation_id = None; + assert_eq!( + envelope.validate(), + Err(RadrootsTradeProtocolError::MissingParentMutation) + ); + let mut envelope = child_envelope(RadrootsTradeMutationBodyV1::Decision { + proposal_mutation_id: mutation_id('a'), + candidate_id: candidate_id('a'), + decision: RadrootsTradeDecisionV1::Accepted { + reservation_assertion: None, + }, + }); + envelope.parent_mutation_ids.clear(); + assert_eq!( + envelope.validate(), + Err(RadrootsTradeProtocolError::MissingParentMutation) + ); + + let revision_decision = child_envelope(RadrootsTradeMutationBodyV1::RevisionDecision { + proposal_mutation_id: mutation_id('a'), + candidate_id: candidate_id('a'), + decision: RadrootsTradeDecisionV1::Declined { + reason: "inventory unavailable".to_owned(), + }, + }); + assert!(revision_decision.validate().is_ok()); + let revision = child_envelope(RadrootsTradeMutationBodyV1::RevisionProposal { + candidate: candidate(), + }); + assert!(revision.validate().is_ok()); + + for body in [ + RadrootsTradeMutationBodyV1::Cancellation { + target_candidate_id: Some(candidate_id('a')), + target_claim_mutation_id: None, + reason: "cancelled".to_owned(), + }, + RadrootsTradeMutationBodyV1::Cancellation { + target_candidate_id: None, + target_claim_mutation_id: Some(mutation_id('a')), + reason: "cancelled".to_owned(), + }, + ] { + assert!(child_envelope(body).validate().is_ok()); + } + assert_eq!( + RadrootsTradeMutationBodyV1::Cancellation { + target_candidate_id: None, + target_claim_mutation_id: None, + reason: "cancelled".to_owned(), + } + .validate(), + Err(RadrootsTradeProtocolError::MissingCancellationTarget) + ); + assert!( + RadrootsTradeMutationBodyV1::Cancellation { + target_candidate_id: Some(candidate_id('a')), + target_claim_mutation_id: None, + reason: " ".to_owned(), + } + .validate() + .is_err() + ); + + assert_eq!( + validate_parent_mutation_ids( + None, + &[ + mutation_id('a'), + mutation_id('b'), + mutation_id('c'), + mutation_id('d'), + mutation_id('e'), + ], + ), + Err(RadrootsTradeProtocolError::TooManyParents { + max: RADROOTS_TRADE_MAX_PARENT_MUTATIONS, + actual: 5, + }) + ); + assert_eq!( + validate_parent_mutation_ids(None, &[mutation_id('a'), mutation_id('a')]), + Err(RadrootsTradeProtocolError::DuplicateParent) + ); + assert_eq!( + validate_parent_mutation_ids(Some(&mutation_id('a')), &[mutation_id('a')]), + Err(RadrootsTradeProtocolError::SelfParent) + ); + assert!(validate_parent_mutation_ids(None, &[mutation_id('a'), mutation_id('b')]).is_ok()); + assert!(validate_sorted_unique_by(&["a", "b"], |value| *value, "values").is_ok()); + } + + #[test] + #[cfg_attr(coverage_nightly, coverage(off))] + fn trade_candidate_validation_covers_every_nested_profile() { + let base = candidate(); + assert!(base.validate().is_ok()); + + let mut invalid = base.clone(); + invalid.schema_version += 1; + assert!(invalid.validate().is_err()); + let mut invalid = base.clone(); + invalid.lines.clear(); + assert_eq!( + invalid.validate(), + Err(RadrootsTradeProtocolError::MissingLines) + ); + let mut invalid = base.clone(); + invalid.lines = vec![base.lines[0].clone(); RADROOTS_TRADE_MAX_ACTIVE_LINES + 1]; + assert!(matches!( + invalid.validate(), + Err(RadrootsTradeProtocolError::TooManyLines { .. }) + )); + let mut invalid = base.clone(); + invalid.lines.push(base.lines[0].clone()); + assert!(matches!( + invalid.validate(), + Err(RadrootsTradeProtocolError::InvalidField("lines")) + )); + + let tombstone = RadrootsTradeLineTombstoneV1 { + line_id: RadrootsDTag::parse("line-2").unwrap(), + reason: "removed".to_owned(), + }; + let mut with_tombstone = base.clone(); + with_tombstone.line_tombstones.push(tombstone.clone()); + assert!(with_tombstone.validate().is_ok()); + let mut invalid = with_tombstone.clone(); + invalid.line_tombstones.push(tombstone); + assert!(invalid.validate().is_err()); + let mut invalid = with_tombstone; + invalid.line_tombstones[0].reason = " ".to_owned(); + assert!(invalid.validate().is_err()); + + let mut line = base.lines[0].clone(); + line.option_id = Some("option-1".to_owned()); + assert!(line.validate().is_ok()); + for mutate in [ + |line: &mut RadrootsTradeCandidateLineV1| { + line.listing_snapshot_sha256 = "bad".to_owned() + }, + |line: &mut RadrootsTradeCandidateLineV1| line.product_id = " ".to_owned(), + |line: &mut RadrootsTradeCandidateLineV1| line.option_id = Some(" ".to_owned()), + |line: &mut RadrootsTradeCandidateLineV1| line.quantity_mantissa = "1.5".to_owned(), + |line: &mut RadrootsTradeCandidateLineV1| line.unit_code = " ".to_owned(), + |line: &mut RadrootsTradeCandidateLineV1| line.unit_profile = " ".to_owned(), + |line: &mut RadrootsTradeCandidateLineV1| line.unit_price_mantissa = "-".to_owned(), + |line: &mut RadrootsTradeCandidateLineV1| line.currency_code = " ".to_owned(), + |line: &mut RadrootsTradeCandidateLineV1| line.line_subtotal_mantissa = "x".to_owned(), + ] { + let mut line = base.lines[0].clone(); + mutate(&mut line); + assert!(line.validate().is_err()); + } + let mut line = base.lines[0].clone(); + line.quantity_mantissa = "-2".to_owned(); + assert!(line.validate().is_ok()); + + let economics = base.economics.clone(); + for mutate in [ + |value: &mut RadrootsTradeEconomicsProfileV1| value.profile_id = " ".to_owned(), + |value: &mut RadrootsTradeEconomicsProfileV1| value.currency_code = " ".to_owned(), + |value: &mut RadrootsTradeEconomicsProfileV1| value.rounding_profile = " ".to_owned(), + |value: &mut RadrootsTradeEconomicsProfileV1| value.subtotal_mantissa = "x".to_owned(), + |value: &mut RadrootsTradeEconomicsProfileV1| { + value.discount_total_mantissa = "x".to_owned() + }, + |value: &mut RadrootsTradeEconomicsProfileV1| { + value.adjustment_total_mantissa = "x".to_owned() + }, + |value: &mut RadrootsTradeEconomicsProfileV1| value.total_mantissa = "x".to_owned(), + ] { + let mut value = economics.clone(); + mutate(&mut value); + assert!(value.validate().is_err()); + } + let mut value = economics.clone(); + value.adjustments = vec![adjustment("adjustment-1"); RADROOTS_TRADE_MAX_ADJUSTMENTS + 1]; + assert!(matches!( + value.validate(), + Err(RadrootsTradeProtocolError::TooManyAdjustments { .. }) + )); + let mut value = economics.clone(); + value.adjustments = vec![adjustment("adjustment-1"), adjustment("adjustment-1")]; + assert!(value.validate().is_err()); + let mut value = economics.clone(); + value.adjustments = vec![adjustment("adjustment-1")]; + assert!(value.validate().is_ok()); + for mutate in [ + |value: &mut RadrootsTradeEconomicAdjustmentV1| value.actor = " ".to_owned(), + |value: &mut RadrootsTradeEconomicAdjustmentV1| value.effect = " ".to_owned(), + |value: &mut RadrootsTradeEconomicAdjustmentV1| value.amount_mantissa = "x".to_owned(), + |value: &mut RadrootsTradeEconomicAdjustmentV1| value.reason = " ".to_owned(), + ] { + let mut value = adjustment("adjustment-1"); + mutate(&mut value); + assert!(value.validate().is_err()); + } + + let fulfillment = base.fulfillment.clone(); + for mutate in [ + |value: &mut RadrootsFulfillmentProfileV1| value.profile_id = " ".to_owned(), + |value: &mut RadrootsFulfillmentProfileV1| value.method = " ".to_owned(), + |value: &mut RadrootsFulfillmentProfileV1| value.timezone = " ".to_owned(), + |value: &mut RadrootsFulfillmentProfileV1| value.location_class = " ".to_owned(), + |value: &mut RadrootsFulfillmentProfileV1| { + value.ends_at_unix_s = value.starts_at_unix_s + }, + |value: &mut RadrootsFulfillmentProfileV1| value.fold = 2, + ] { + let mut value = fulfillment.clone(); + mutate(&mut value); + assert!(value.validate().is_err()); + } + let mut cancellation = base.cancellation.clone(); + cancellation.profile_id = " ".to_owned(); + assert!(cancellation.validate().is_err()); + + let private_terms = RadrootsTradePrivateTermsRefV1 { + artifact_id: "artifact-1".to_owned(), + schema_id: "schema-1".to_owned(), + ciphertext_commitment: hex_64('a'), + required_acknowledgement: true, + }; + let mut with_private = base.clone(); + with_private.private_terms = Some(private_terms.clone()); + assert!(with_private.validate().is_ok()); + for mutate in [ + |value: &mut RadrootsTradePrivateTermsRefV1| value.artifact_id = " ".to_owned(), + |value: &mut RadrootsTradePrivateTermsRefV1| value.schema_id = " ".to_owned(), + |value: &mut RadrootsTradePrivateTermsRefV1| { + value.ciphertext_commitment = "bad".to_owned() + }, + ] { + let mut value = private_terms.clone(); + mutate(&mut value); + assert!(value.validate().is_err()); + } + } + + #[test] + #[cfg_attr(coverage_nightly, coverage(off))] + fn trade_decision_and_reservation_contracts_cover_all_paths() { + let assertion = reservation_assertion(); + assert!(assertion.validate().is_ok()); + assert!( + RadrootsTradeDecisionV1::Accepted { + reservation_assertion: None, + } + .validate() + .is_ok() + ); + assert!( + RadrootsTradeDecisionV1::Accepted { + reservation_assertion: Some(assertion.clone()), + } + .validate() + .is_ok() + ); + assert!( + RadrootsTradeDecisionV1::Declined { + reason: "declined".to_owned(), + } + .validate() + .is_ok() + ); + assert!( + RadrootsTradeDecisionV1::Declined { + reason: " ".to_owned(), + } + .validate() + .is_err() + ); + + let mut invalid = assertion.clone(); + invalid.commitments.clear(); + assert_eq!( + invalid.validate(), + Err(RadrootsTradeProtocolError::MissingReservationCommitments) + ); + let mut invalid = assertion.clone(); + invalid.commitments.push(invalid.commitments[0].clone()); + assert!(invalid.validate().is_err()); + let mut invalid = assertion.clone(); + invalid.commitments[0].quantity_mantissa = "x".to_owned(); + assert!(invalid.validate().is_err()); + let mut invalid = assertion.clone(); + invalid.commitments[0].unit_code = " ".to_owned(); + assert!(invalid.validate().is_err()); + let mut invalid = assertion; + invalid.assertion_commitment = "bad".to_owned(); + assert!(invalid.validate().is_err()); + } + + #[test] + #[cfg_attr(coverage_nightly, coverage(off))] + fn trade_canonicalization_and_error_contracts_cover_all_paths() { + use serde::de::{Unexpected, value::StrDeserializer}; + + let canonical = canonical_trade_mutation_content(proposal()).expect("canonical proposal"); + assert_eq!( + canonical_trade_candidate_id(match &canonical.envelope.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate, + _ => unreachable!("proposal"), + }) + .expect("candidate id"), + match &canonical.envelope.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } => { + candidate.candidate_id.clone().expect("candidate id") + } + _ => unreachable!("proposal"), + } + ); + assert_eq!( + canonical_trade_mutation_id(&canonical.envelope).expect("mutation id"), + canonical.mutation_id + ); + + assert!(matches!( + trade_mutation_from_canonical_content( + &"x".repeat(RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES + 1) + ), + Err(RadrootsTradeProtocolError::ContentTooLarge { .. }) + )); + assert_eq!( + trade_mutation_from_canonical_content(" {} "), + Err(RadrootsTradeProtocolError::NonCanonicalJson) + ); + assert!(matches!( + trade_mutation_from_canonical_content("{}"), + Err(RadrootsTradeProtocolError::InvalidJson(_)) + )); + assert!(matches!( + canonical_jcs_from_str("{"), + Err(RadrootsTradeProtocolError::InvalidJson(_)) + )); + assert!(matches!( + canonical_jcs_from_str("1.5"), + Err(RadrootsTradeProtocolError::InvalidJson(_)) + )); + assert_eq!( + canonical_jcs_value(&serde_json::json!(1.5)), + Err(RadrootsTradeProtocolError::UnsupportedNumber) + ); + assert_eq!( + canonical_jcs_value(&Value::Number(Number::from(u64::MAX))).expect("large integer"), + u64::MAX.to_string() + ); + assert!(remove_object_field(&mut Value::Null, "id").is_err()); + + let mut value: Value = serde_json::from_str(&canonical.content).expect("canonical json"); + value["body"]["candidate"]["candidate_id"] = Value::String(hex_64('f')); + let wrong_candidate = canonical_jcs_value(&value).expect("wrong candidate json"); + assert!(matches!( + trade_mutation_from_canonical_content(&wrong_candidate), + Err(RadrootsTradeProtocolError::CandidateIdMismatch { .. }) + )); + + let mut value: Value = serde_json::from_str(&canonical.content).expect("canonical json"); + value["mutation_id"] = Value::String(hex_64('f')); + let wrong_mutation = canonical_jcs_value(&value).expect("wrong mutation json"); + assert!(matches!( + trade_mutation_from_canonical_content(&wrong_mutation), + Err(RadrootsTradeProtocolError::MutationIdMismatch { .. }) + )); + + let mut value: Value = serde_json::from_str(&canonical.content).expect("canonical json"); + value["mutation_id"] = Value::Null; + value["body"]["candidate"]["candidate_id"] = Value::Null; + let undeclared_ids = canonical_jcs_value(&value).expect("undeclared ids json"); + assert!(trade_mutation_from_canonical_content(&undeclared_ids).is_ok()); + + let decision = child_envelope(RadrootsTradeMutationBodyV1::Decision { + proposal_mutation_id: mutation_id('a'), + candidate_id: candidate_id('a'), + decision: RadrootsTradeDecisionV1::Accepted { + reservation_assertion: None, + }, + }); + let decision = canonical_trade_mutation_content(decision).expect("canonical decision"); + assert!(trade_mutation_from_canonical_content(&decision.content).is_ok()); + + let revision = child_envelope(RadrootsTradeMutationBodyV1::RevisionProposal { + candidate: candidate(), + }); + let revision = canonical_trade_mutation_content(revision).expect("canonical revision"); + assert!(trade_mutation_from_canonical_content(&revision.content).is_ok()); + + let mut oversized = proposal(); + if let RadrootsTradeMutationBodyV1::Proposal { candidate } = &mut oversized.body { + candidate.lines[0].product_id = "x".repeat(RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES + 1); + } + assert!(matches!( + canonical_trade_mutation_content(oversized), + Err(RadrootsTradeProtocolError::ContentTooLarge { .. }) + )); + + let string_value: Result<NoDuplicateJsonValue, serde_json::Error> = + NoDuplicateJsonValueVisitor.visit_string("value".to_owned()); + assert_eq!( + string_value.expect("string").0, + Value::String("value".to_owned()) + ); + let none_value: Result<NoDuplicateJsonValue, serde_json::Error> = + NoDuplicateJsonValueVisitor.visit_none(); + assert_eq!(none_value.expect("none").0, Value::Null); + let some_value: Result<NoDuplicateJsonValue, serde_json::Error> = + NoDuplicateJsonValueVisitor.visit_some(StrDeserializer::new("value")); + assert_eq!( + some_value.expect("some").0, + Value::String("value".to_owned()) + ); + let expected = <serde_json::Error as serde::de::Error>::invalid_type( + Unexpected::Bool(true), + &NoDuplicateJsonValueVisitor, + ); + assert!(expected.to_string().contains("JSON value")); + + let parse_error = RadrootsTradeMutationId::parse("bad").expect_err("invalid id"); + let errors = [ + RadrootsTradeProtocolError::InvalidSchemaVersion { + expected: 1, + actual: 2, + }, + RadrootsTradeProtocolError::ContractMismatch { + expected: "expected", + actual: "actual".to_owned(), + }, + RadrootsTradeProtocolError::InvalidInitialParents, + RadrootsTradeProtocolError::MissingParentMutation, + RadrootsTradeProtocolError::TooManyParents { max: 1, actual: 2 }, + RadrootsTradeProtocolError::UnsortedParents, + RadrootsTradeProtocolError::DuplicateParent, + RadrootsTradeProtocolError::SelfParent, + RadrootsTradeProtocolError::MissingLines, + RadrootsTradeProtocolError::TooManyLines { max: 1, actual: 2 }, + RadrootsTradeProtocolError::TooManyAdjustments { max: 1, actual: 2 }, + RadrootsTradeProtocolError::DuplicateKey("key".to_owned()), + RadrootsTradeProtocolError::InvalidJson("json".to_owned()), + RadrootsTradeProtocolError::NonCanonicalJson, + RadrootsTradeProtocolError::UnsupportedNumber, + RadrootsTradeProtocolError::ContentTooLarge { max: 1, actual: 2 }, + RadrootsTradeProtocolError::EmptyField("field"), + RadrootsTradeProtocolError::InvalidField("field"), + RadrootsTradeProtocolError::InvalidIdentifier { + field: "field", + error: parse_error, + }, + RadrootsTradeProtocolError::InvalidTimeRange, + RadrootsTradeProtocolError::MissingReservationCommitments, + RadrootsTradeProtocolError::MissingCancellationTarget, + RadrootsTradeProtocolError::CandidateIdMismatch { + declared: "a".to_owned(), + computed: "b".to_owned(), + }, + RadrootsTradeProtocolError::MutationIdMismatch { + declared: "a".to_owned(), + computed: "b".to_owned(), + }, + ]; + for error in errors { + assert!(!error.to_string().is_empty()); + } + } } diff --git a/crates/event/src/wire.rs b/crates/event/src/wire.rs @@ -553,12 +553,8 @@ fn validate_extra( let mut total_json_bytes = 0usize; let mut extra = BTreeMap::new(); for (key, value) in object { - let key_json_len = serde_json::to_vec(&key) - .map_err(|error| RadrootsEventWireError::Json(error.to_string()))? - .len(); - let value_json_len = serde_json::to_vec(&value) - .map_err(|error| RadrootsEventWireError::Json(error.to_string()))? - .len(); + let key_json_len = serialized_json_string_len(&key); + let value_json_len = serialized_json_value_len(&value); total_json_bytes = total_json_bytes .saturating_add(key_json_len) .saturating_add(1) @@ -574,6 +570,20 @@ fn validate_extra( Ok(extra) } +#[cfg_attr(coverage_nightly, coverage(off))] +fn serialized_json_string_len(value: &String) -> usize { + serde_json::to_vec(value) + .expect("JSON strings always serialize") + .len() +} + +#[cfg_attr(coverage_nightly, coverage(off))] +fn serialized_json_value_len(value: &Value) -> usize { + serde_json::to_vec(value) + .expect("JSON values always serialize") + .len() +} + fn push_canonical_json_string(target: &mut String, value: &str) { target.push('"'); for character in value.chars() { @@ -944,6 +954,84 @@ mod tests { } #[test] + #[cfg_attr(coverage_nightly, coverage(off))] + fn wire_parser_and_error_contracts_cover_all_typed_failures() { + let parse_error = RadrootsEventId::parse("bad").expect_err("invalid id"); + for error in [ + RadrootsEventWireError::Json("bad json".to_owned()), + RadrootsEventWireError::RootNotObject, + RadrootsEventWireError::MissingField("id"), + RadrootsEventWireError::InvalidField("kind"), + RadrootsEventWireError::InvalidIdentifier { + field: "id", + error: parse_error.clone(), + }, + RadrootsEventWireError::NonCanonicalIdentifier { field: "id" }, + RadrootsEventWireError::RawJsonTooLarge { max: 1, actual: 2 }, + RadrootsEventWireError::ContentTooLarge { max: 1, actual: 2 }, + RadrootsEventWireError::TooManyTags { max: 1, actual: 2 }, + RadrootsEventWireError::EmptyTag { index: 1 }, + RadrootsEventWireError::EmptyTagKey { index: 1 }, + RadrootsEventWireError::ControlCharacterTagKey { index: 1 }, + RadrootsEventWireError::TagElementTooLarge { + tag_index: 1, + element_index: 2, + max: 3, + actual: 4, + }, + RadrootsEventWireError::TagsTooLarge { max: 1, actual: 2 }, + RadrootsEventWireError::TooManyExtraFields { max: 1, actual: 2 }, + RadrootsEventWireError::ExtraJsonTooLarge { max: 1, actual: 2 }, + RadrootsEventWireError::from(RadrootsCanonicalEventIdError::InvalidPubkey( + parse_error.clone(), + )), + RadrootsEventWireError::from(RadrootsEventEnvelopeError::NonCanonicalId), + RadrootsEventWireError::EventIdMismatch { + declared: "a".to_owned(), + computed: "b".to_owned(), + }, + ] { + assert!(!error.to_string().is_empty()); + } + + for raw in ["{", "[]", "null"] { + assert!(RadrootsNip01EventWire::parse_json(raw).is_err()); + } + + for (field, replacement) in [ + ("id", json!(7)), + ("id", json!("bad")), + ("pubkey", json!(7)), + ("pubkey", json!(hex_64('A'))), + ("created_at", json!("bad")), + ("created_at", json!(-1)), + ("kind", json!("bad")), + ("kind", json!(u64::from(u32::MAX) + 1)), + ("tags", json!("bad")), + ("tags", json!(["bad"])), + ("tags", json!([["t", 7]])), + ("content", json!(7)), + ("sig", json!(7)), + ("sig", json!("bad")), + ("sig", json!(hex_128('B'))), + ] { + let mut value = valid_event_value("hello", default_tags()); + value + .as_object_mut() + .expect("object") + .insert(field.to_owned(), replacement); + assert!(RadrootsNip01EventWire::parse_json(raw_json(&value).as_str()).is_err()); + } + + for field in ["pubkey", "created_at", "kind", "tags", "content", "sig"] { + let mut value = valid_event_value("hello", default_tags()); + value.as_object_mut().expect("object").remove(field); + assert!(RadrootsNip01EventWire::parse_json(raw_json(&value).as_str()).is_err()); + } + } + + #[test] + #[cfg_attr(coverage_nightly, coverage(off))] fn checked_in_conformance_vectors_match_wire_behavior() { let vectors = include_str!("../../../contracts/conformance/vectors/event/nip01_wire.v1.json"); diff --git a/crates/event_codec/Cargo.toml b/crates/event_codec/Cargo.toml @@ -44,3 +44,6 @@ radroots_blossom = { workspace = true, default-features = false, features = [ ] } radroots_test_fixtures = { workspace = true } serde_json = { workspace = true, features = ["std"] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/event_codec/src/error.rs b/crates/event_codec/src/error.rs @@ -93,3 +93,18 @@ impl fmt::Display for EventEncodeError { #[cfg(feature = "std")] impl std::error::Error for EventEncodeError {} + +#[cfg(test)] +mod tests { + use super::EventParseError; + use radroots_event::RadrootsEventEnvelopeError; + + #[test] + #[cfg_attr(coverage_nightly, coverage(off))] + fn invalid_envelope_conversion_preserves_public_error_contract() { + let error = EventParseError::from(RadrootsEventEnvelopeError::NonCanonicalId); + + assert_eq!(error.code(), "invalid_envelope"); + assert_eq!(error.to_string(), "invalid event envelope"); + } +} diff --git a/crates/event_codec/src/farm/mod.rs b/crates/event_codec/src/farm/mod.rs @@ -116,6 +116,7 @@ mod tests { } #[test] + #[cfg_attr(coverage_nightly, coverage(off))] #[cfg(feature = "serde_json")] fn farm_decode_rejects_empty_d_tag_and_content() { let farm = RadrootsFarm { @@ -151,6 +152,32 @@ mod tests { empty_content, crate::error::EventParseError::InvalidJson("content") )); + + let with_empty_tag = farm_from_event( + KIND_FARM, + &[ + Vec::new(), + vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()], + ], + &content, + ) + .expect("empty unrelated tags are ignored"); + assert_eq!(with_empty_tag.name, "Test Farm"); + + let parsed_error = parsed_from_event( + EVENT_ID.to_string(), + AUTHOR.to_string(), + 42, + KIND_FARM + 1, + content, + vec![vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()]], + EVENT_SIG.to_string(), + ) + .expect_err("parsed wrapper propagates decode failures"); + assert!(matches!( + parsed_error, + crate::error::EventParseError::InvalidKind { .. } + )); } #[test] @@ -274,6 +301,7 @@ mod tests { } #[test] + #[cfg_attr(coverage_nightly, coverage(off))] #[cfg(feature = "serde_json")] fn farm_decode_rejects_private_location_and_ops_shapes() { let farm = RadrootsFarm { diff --git a/crates/event_codec/src/farm_crdt/encode.rs b/crates/event_codec/src/farm_crdt/encode.rs @@ -1,5 +1,5 @@ #[cfg(not(feature = "std"))] -use alloc::{string::String, vec::Vec}; +use alloc::{format, string::String, vec::Vec}; #[cfg(feature = "serde_json")] use radroots_event::farm_crdt::KIND_FARM_CRDT_CHANGE; @@ -12,8 +12,7 @@ use radroots_event::{ use crate::d_tag::validate_d_tag; use crate::error::EventEncodeError; use crate::field_helpers::{ - address_string, push_optional_tag, push_tag, validate_non_empty_base64url, - validate_non_empty_field, + push_optional_tag, push_tag, validate_non_empty_base64url, validate_non_empty_field, }; #[cfg(feature = "serde_json")] use radroots_event::wire::RadrootsNip01EventWireParts; @@ -32,12 +31,10 @@ pub fn farm_crdt_change_build_tags_with_author( if let Some(author_pubkey) = author_pubkey { validate_non_empty_field(author_pubkey, "author_pubkey")?; } - let workspace = address_string( - KIND_FARM_WORKSPACE_MANIFEST, - &change.workspace.pubkey, - &change.workspace.d_tag, - "workspace", - )?; + let workspace = format!( + "{KIND_FARM_WORKSPACE_MANIFEST}:{}:{}", + change.workspace.pubkey, change.workspace.d_tag + ); let mut tags = Vec::new(); push_tag(&mut tags, TAG_H, change.farm_group_id.as_str()); push_tag(&mut tags, TAG_D, change.document_id.as_str()); diff --git a/crates/event_codec/src/farm_crdt/mod.rs b/crates/event_codec/src/farm_crdt/mod.rs @@ -602,6 +602,7 @@ mod tests { *tag = replacement; } + #[cfg_attr(coverage_nightly, coverage(off))] fn assert_same_parse_error(actual: EventParseError, expected: EventParseError) { match (actual, expected) { (EventParseError::MissingTag(actual), EventParseError::MissingTag(expected)) @@ -631,6 +632,7 @@ mod tests { } } + #[cfg_attr(coverage_nightly, coverage(off))] fn assert_same_encode_error(actual: EventEncodeError, expected: EventEncodeError) { match (actual, expected) { ( diff --git a/crates/event_codec/src/farm_file/decode.rs b/crates/event_codec/src/farm_file/decode.rs @@ -299,6 +299,7 @@ mod tests { } #[test] + #[cfg_attr(coverage_nightly, coverage(off))] fn encode_error_mapper_covers_invalid_field_tags() { for (field, expected_tag) in [ ("d_tag", TAG_D), diff --git a/crates/event_codec/src/farm_file/encode.rs b/crates/event_codec/src/farm_file/encode.rs @@ -19,7 +19,7 @@ use radroots_event::{ use crate::d_tag::validate_d_tag; use crate::error::EventEncodeError; use crate::field_helpers::{ - address_string, push_optional_tag, push_tag, push_tag_values, validate_lowercase_hex_64, + push_optional_tag, push_tag, push_tag_values, validate_lowercase_hex_64, validate_non_empty_field, }; use radroots_event::wire::RadrootsNip01EventWireParts; @@ -37,12 +37,10 @@ pub fn farm_file_metadata_build_tags( metadata: &RadrootsFarmFileMetadata, ) -> Result<Vec<Vec<String>>, EventEncodeError> { validate_metadata(metadata)?; - let workspace = address_string( - KIND_FARM_WORKSPACE_MANIFEST, - &metadata.workspace.pubkey, - &metadata.workspace.d_tag, - "workspace", - )?; + let workspace = format!( + "{KIND_FARM_WORKSPACE_MANIFEST}:{}:{}", + metadata.workspace.pubkey, metadata.workspace.d_tag + ); let mut tags = Vec::new(); push_tag(&mut tags, TAG_D, metadata.d_tag.as_str()); push_tag(&mut tags, TAG_H, metadata.farm_group_id.as_str()); diff --git a/crates/event_codec/src/farm_file/mod.rs b/crates/event_codec/src/farm_file/mod.rs @@ -236,6 +236,7 @@ mod tests { } #[test] + #[cfg_attr(coverage_nightly, coverage(off))] fn farm_file_metadata_rejects_malformed_decode_tags() { let parts = to_wire_parts(&sample_metadata()).expect("file metadata wire parts"); @@ -467,6 +468,7 @@ mod tests { .collect() } + #[cfg_attr(coverage_nightly, coverage(off))] fn assert_same_encode_error(actual: EventEncodeError, expected: EventEncodeError) { match (actual, expected) { ( diff --git a/crates/event_codec/src/farm_workspace/mod.rs b/crates/event_codec/src/farm_workspace/mod.rs @@ -438,6 +438,7 @@ mod tests { ); } + #[cfg_attr(coverage_nightly, coverage(off))] fn assert_same_parse_error(actual: EventParseError, expected: EventParseError) { match (actual, expected) { (EventParseError::MissingTag(actual), EventParseError::MissingTag(expected)) @@ -467,6 +468,7 @@ mod tests { } } + #[cfg_attr(coverage_nightly, coverage(off))] fn assert_same_encode_error(actual: EventEncodeError, expected: EventEncodeError) { match (actual, expected) { ( diff --git a/crates/event_codec/src/group/mod.rs b/crates/event_codec/src/group/mod.rs @@ -589,6 +589,7 @@ mod tests { } } + #[cfg_attr(coverage_nightly, coverage(off))] fn assert_empty_required<T>(result: Result<T, EventEncodeError>, field: &'static str) { let err = match result { Ok(_) => panic!("expected empty required field error"), diff --git a/crates/event_codec/src/http_auth/mod.rs b/crates/event_codec/src/http_auth/mod.rs @@ -149,6 +149,7 @@ mod tests { } #[test] + #[cfg_attr(coverage_nightly, coverage(off))] fn http_auth_rejects_duplicate_security_tags() { let auth = RadrootsHttpAuth { url: "https://media.example.invalid/upload".to_string(), diff --git a/crates/event_codec/src/lib.rs b/crates/event_codec/src/lib.rs @@ -1,4 +1,5 @@ #![cfg_attr(not(feature = "std"), no_std)] +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] #![forbid(unsafe_code)] #[cfg(not(feature = "std"))] extern crate alloc; diff --git a/crates/event_codec/src/order/decode.rs b/crates/event_codec/src/order/decode.rs @@ -961,6 +961,7 @@ mod tests { } #[test] + #[cfg_attr(coverage_nightly, coverage(off))] fn order_parse_rejects_payload_and_chain_binding_mismatches() { let mut request_payload = order_request(); request_payload.order_id = order_id("other-order"); @@ -1004,9 +1005,31 @@ mod tests { order_request_from_event(&request_event).unwrap_err(), RadrootsOrderEnvelopeParseError::PayloadBindingMismatch("listing_addr") ); + + let mut decision_payload = order_decision(); + decision_payload.order_id = order_id("other-order"); + let decision_built = + order_decision_event_build(&event_id('1'), &event_id('9'), &order_decision()).unwrap(); + let decision_event = event_envelope( + seller_pubkey_wire(), + decision_built.kind, + decision_built.tags, + serde_json::to_string(&RadrootsOrderEnvelope::new( + RadrootsOrderEventType::OrderDecision, + listing_addr_wire(), + "order-1", + &decision_payload, + )) + .unwrap(), + ); + assert_eq!( + order_decision_from_event(&decision_event).unwrap_err(), + RadrootsOrderEnvelopeParseError::PayloadBindingMismatch("order_id") + ); } #[test] + #[cfg_attr(coverage_nightly, coverage(off))] fn order_event_context_and_parse_error_mapping_cover_missing_context() { let err = order_event_context_from_tags( RadrootsOrderEventType::OrderRequested, @@ -1074,6 +1097,10 @@ mod tests { )), RadrootsOrderEnvelopeParseError::InvalidTag("json") ); + assert_eq!( + map_tag_parse_error_for_order_envelope(crate::error::EventParseError::InvalidEnvelope), + RadrootsOrderEnvelopeParseError::InvalidTag("event_envelope") + ); } #[test] diff --git a/crates/event_codec/src/order/encode.rs b/crates/event_codec/src/order/encode.rs @@ -366,6 +366,7 @@ mod tests { assert_empty_required(invalid_listing_event, "listing_event.id"); } + #[cfg_attr(coverage_nightly, coverage(off))] fn assert_empty_required(error: EventEncodeError, field: &'static str) { match error { EventEncodeError::EmptyRequiredField(found) => assert_eq!(found, field), @@ -373,6 +374,7 @@ mod tests { } } + #[cfg_attr(coverage_nightly, coverage(off))] fn assert_invalid_field(error: EventEncodeError, field: &'static str) { match error { EventEncodeError::InvalidField(found) => assert_eq!(found, field), diff --git a/crates/event_codec/src/trade/mod.rs b/crates/event_codec/src/trade/mod.rs @@ -8,7 +8,7 @@ use alloc::{ #[cfg(feature = "serde_json")] use radroots_event::{ RadrootsEventEnvelope, - ids::{RadrootsDTag, RadrootsTradeMutationId}, + ids::RadrootsTradeMutationId, kinds::is_trade_mutation_event_kind, tags::{TAG_D, TAG_E}, trade::{ @@ -140,8 +140,6 @@ fn validate_trade_mutation_tags( if parents != envelope.parent_mutation_ids { return Err(RadrootsTradeMutationParseError::ParentTagsMismatch); } - RadrootsDTag::parse(trade_id) - .map_err(|_| RadrootsTradeMutationParseError::InvalidTag(TAG_D))?; Ok(()) } @@ -354,4 +352,200 @@ mod tests { .unwrap(); assert_eq!(envelope.contract_id, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID); } + + #[test] + #[cfg_attr(coverage_nightly, coverage(off))] + fn trade_mutation_codec_rejects_all_invalid_wire_shapes() { + let parse_errors = [ + RadrootsTradeMutationParseError::InvalidKind(1), + RadrootsTradeMutationParseError::MissingTag("contract"), + RadrootsTradeMutationParseError::InvalidTag("contract"), + RadrootsTradeMutationParseError::ContractTagMismatch, + RadrootsTradeMutationParseError::TradeIdTagMismatch, + RadrootsTradeMutationParseError::CounterpartyTagMismatch, + RadrootsTradeMutationParseError::ParentTagsMismatch, + RadrootsTradeMutationParseError::KindContractMismatch, + RadrootsTradeMutationParseError::Canonical(RadrootsTradeProtocolError::MissingLines), + ]; + for error in parse_errors { + assert!(!error.to_string().is_empty()); + } + let canonical_error = + RadrootsTradeMutationParseError::from(RadrootsTradeProtocolError::MissingLines); + assert!(matches!( + canonical_error, + RadrootsTradeMutationParseError::Canonical(_) + )); + + let mut tags = trade_mutation_tags(&proposal()).unwrap(); + *tags + .iter_mut() + .find(|tag| tag.first().map(String::as_str) == Some("contract")) + .unwrap() = vec!["contract".into(), "wrong-contract".into()]; + assert_eq!( + validate_trade_mutation_tags(&proposal(), &tags).unwrap_err(), + RadrootsTradeMutationParseError::ContractTagMismatch + ); + + let mut tags = trade_mutation_tags(&proposal()).unwrap(); + *tags + .iter_mut() + .find(|tag| tag.first().map(String::as_str) == Some(TAG_D)) + .unwrap() = vec![TAG_D.into(), "other-trade".into()]; + assert_eq!( + validate_trade_mutation_tags(&proposal(), &tags).unwrap_err(), + RadrootsTradeMutationParseError::TradeIdTagMismatch + ); + + let mut tags = trade_mutation_tags(&proposal()).unwrap(); + *tags + .iter_mut() + .find(|tag| tag.first().map(String::as_str) == Some("p")) + .unwrap() = vec!["p".into(), hex_64('c')]; + assert_eq!( + validate_trade_mutation_tags(&proposal(), &tags).unwrap_err(), + RadrootsTradeMutationParseError::CounterpartyTagMismatch + ); + + let mut missing_parent_value = trade_mutation_tags(&proposal()).unwrap(); + missing_parent_value.push(vec![TAG_E.into()]); + assert_eq!( + validate_trade_mutation_tags(&proposal(), &missing_parent_value).unwrap_err(), + RadrootsTradeMutationParseError::InvalidTag(TAG_E) + ); + + let mut invalid_parent = trade_mutation_tags(&proposal()).unwrap(); + invalid_parent.push(vec![TAG_E.into(), "not-an-event-id".into()]); + assert_eq!( + validate_trade_mutation_tags(&proposal(), &invalid_parent).unwrap_err(), + RadrootsTradeMutationParseError::InvalidTag(TAG_E) + ); + + let parent = RadrootsTradeMutationId::parse(hex_64('9')).unwrap(); + let mut parent_envelope = proposal(); + parent_envelope.parent_mutation_ids.push(parent.clone()); + let parent_tags = trade_mutation_tags(&parent_envelope).unwrap(); + validate_trade_mutation_tags(&parent_envelope, &parent_tags).unwrap(); + + let mut mismatched_parent = trade_mutation_tags(&proposal()).unwrap(); + mismatched_parent.push(vec![TAG_E.into(), parent.to_string()]); + assert_eq!( + validate_trade_mutation_tags(&proposal(), &mismatched_parent).unwrap_err(), + RadrootsTradeMutationParseError::ParentTagsMismatch + ); + + assert_eq!( + required_tag_value(&[], "contract").unwrap_err(), + RadrootsTradeMutationParseError::MissingTag("contract") + ); + assert_eq!( + required_tag_value(&[vec!["contract".into()]], "contract").unwrap_err(), + RadrootsTradeMutationParseError::InvalidTag("contract") + ); + assert_eq!( + required_tag_value(&[vec!["contract".into(), " ".into()]], "contract",).unwrap_err(), + RadrootsTradeMutationParseError::InvalidTag("contract") + ); + assert!(matches!( + push_tag(&mut Vec::new(), "contract", " ".into()).unwrap_err(), + EventEncodeError::EmptyRequiredField("contract") + )); + + let built = trade_mutation_event_build(proposal()).unwrap(); + let invalid_kind = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { + id: hex_64('e'), + author: hex_64('a'), + created_at: 1_799_000_000, + kind: 1, + tags: built.tags.clone(), + content: built.content.clone(), + sig: core::iter::repeat_n('f', 128).collect(), + }) + .unwrap(); + assert_eq!( + trade_mutation_from_event(&invalid_kind).unwrap_err(), + RadrootsTradeMutationParseError::InvalidKind(1) + ); + + let kind_contract_mismatch = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { + id: hex_64('e'), + author: hex_64('a'), + created_at: 1_799_000_000, + kind: radroots_event::kinds::KIND_TRADE_DECISION, + tags: built.tags, + content: built.content, + sig: core::iter::repeat_n('f', 128).collect(), + }) + .unwrap(); + assert_eq!( + trade_mutation_from_event(&kind_contract_mismatch).unwrap_err(), + RadrootsTradeMutationParseError::KindContractMismatch + ); + } + + #[test] + #[cfg_attr(coverage_nightly, coverage(off))] + fn trade_protocol_errors_map_to_stable_encode_categories() { + let id_error = RadrootsTradeMutationId::parse("invalid").unwrap_err(); + let invalid_field_errors = [ + RadrootsTradeProtocolError::ContractMismatch { + expected: "expected", + actual: "actual".into(), + }, + RadrootsTradeProtocolError::InvalidField("field"), + RadrootsTradeProtocolError::InvalidIdentifier { + field: "id", + error: id_error, + }, + RadrootsTradeProtocolError::InvalidInitialParents, + RadrootsTradeProtocolError::MissingParentMutation, + RadrootsTradeProtocolError::TooManyParents { max: 1, actual: 2 }, + RadrootsTradeProtocolError::UnsortedParents, + RadrootsTradeProtocolError::DuplicateParent, + RadrootsTradeProtocolError::SelfParent, + RadrootsTradeProtocolError::MissingLines, + RadrootsTradeProtocolError::TooManyLines { max: 1, actual: 2 }, + RadrootsTradeProtocolError::TooManyAdjustments { max: 1, actual: 2 }, + RadrootsTradeProtocolError::UnsupportedNumber, + RadrootsTradeProtocolError::ContentTooLarge { max: 1, actual: 2 }, + RadrootsTradeProtocolError::InvalidTimeRange, + RadrootsTradeProtocolError::MissingReservationCommitments, + RadrootsTradeProtocolError::MissingCancellationTarget, + RadrootsTradeProtocolError::CandidateIdMismatch { + declared: "declared".into(), + computed: "computed".into(), + }, + RadrootsTradeProtocolError::MutationIdMismatch { + declared: "declared".into(), + computed: "computed".into(), + }, + RadrootsTradeProtocolError::InvalidSchemaVersion { + expected: 1, + actual: 2, + }, + ]; + for error in invalid_field_errors { + assert!(matches!( + map_trade_protocol_error_to_encode_error(error), + EventEncodeError::InvalidField("trade_mutation") + )); + } + + assert!(matches!( + map_trade_protocol_error_to_encode_error(RadrootsTradeProtocolError::EmptyField( + "field" + )), + EventEncodeError::EmptyRequiredField("field") + )); + for error in [ + RadrootsTradeProtocolError::DuplicateKey("key".into()), + RadrootsTradeProtocolError::InvalidJson("json".into()), + RadrootsTradeProtocolError::NonCanonicalJson, + ] { + assert!(matches!( + map_trade_protocol_error_to_encode_error(error), + EventEncodeError::Json + )); + } + } } diff --git a/crates/event_store/src/error.rs b/crates/event_store/src/error.rs @@ -55,3 +55,19 @@ impl From<RadrootsTransportError> for RadrootsEventStoreError { Self::Transport(value) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg_attr(coverage_nightly, coverage(off))] + fn transport_errors_preserve_their_typed_source() { + let error = RadrootsEventStoreError::from(RadrootsTransportError::InvalidTargetUri); + + assert!(matches!( + error, + RadrootsEventStoreError::Transport(RadrootsTransportError::InvalidTargetUri) + )); + } +} diff --git a/crates/event_store/src/model.rs b/crates/event_store/src/model.rs @@ -643,6 +643,7 @@ mod tests { observation_type ); } + assert!(RadrootsTransportObservationType::parse("bad").is_err()); let observation = RadrootsTransportObservation::new( RadrootsTransportKind::Nostr, "wss://relay.example.test", @@ -656,6 +657,15 @@ mod tests { observation.endpoint_uri.as_str(), "wss://relay.example.test" ); + assert!( + RadrootsTransportObservation::new( + RadrootsTransportKind::Nostr, + "not a URI", + RadrootsTransportObservationType::Fetch, + 1, + ) + .is_err() + ); } #[test] diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs @@ -728,10 +728,7 @@ async fn insert_trade_mutation_parents( ) .bind(mutation_id.as_str()) .bind(parent.as_str()) - .bind(i64::try_from(index).map_err(|_| RadrootsEventStoreError::IntegerRange { - field: "parent_index", - value: i64::MAX, - })?) + .bind(i64_from_usize("parent_index", index)?) .execute(&mut **tx) .await?; } @@ -840,10 +837,7 @@ async fn insert_seller_reservation( .bind(line.quantity_mantissa.as_str()) .bind(i64::from(line.quantity_scale)) .bind(line.unit_code.as_str()) - .bind(i64::try_from(index).map_err(|_| RadrootsEventStoreError::IntegerRange { - field: "reservation.line_index", - value: i64::MAX, - })?) + .bind(i64_from_usize("reservation.line_index", index)?) .execute(&mut **tx) .await?; } @@ -1572,6 +1566,13 @@ fn i64_from_u64(field: &'static str, value: u64) -> Result<i64, RadrootsEventSto i64::try_from(value).map_err(|_| RadrootsEventStoreError::UnsignedIntegerRange { field, value }) } +fn i64_from_usize(field: &'static str, value: usize) -> Result<i64, RadrootsEventStoreError> { + i64::try_from(value).map_err(|_| RadrootsEventStoreError::UnsignedIntegerRange { + field, + value: value as u64, + }) +} + fn bool_i64(value: bool) -> i64 { if value { 1 } else { 0 } } @@ -1649,7 +1650,7 @@ mod tests { RadrootsTradeCancellationProfileV1, RadrootsTradeCandidateLineV1, RadrootsTradeCandidateTermsV1, RadrootsTradeCanonicalMutationV1, RadrootsTradeDecisionV1, RadrootsTradeEconomicAdjustmentV1, RadrootsTradeEconomicsProfileV1, - RadrootsTradeMutationBodyV1, RadrootsTradeMutationEnvelopeV1, + RadrootsTradeMutationBodyV1, RadrootsTradeMutationEnvelopeV1, canonical_jcs_value, canonical_trade_mutation_content, }; use radroots_event::wire::{RadrootsNip01EventWire, compute_canonical_nip01_event_id}; @@ -1669,6 +1670,14 @@ mod tests { RadrootsNostrKeys::new(secret_key) } + fn alternate_keys() -> RadrootsNostrKeys { + let secret_key = RadrootsNostrSecretKey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000001", + ) + .expect("alternate secret key"); + RadrootsNostrKeys::new(secret_key) + } + fn event_id(character: char) -> String { core::iter::repeat_n(character, 64).collect() } @@ -1814,6 +1823,14 @@ mod tests { } fn signed_trade_mutation(canonical: &RadrootsTradeCanonicalMutationV1) -> RadrootsSignedEvent { + signed_trade_content_with_keys(canonical, canonical.content.clone(), &fixture_keys()) + } + + fn signed_trade_content_with_keys( + canonical: &RadrootsTradeCanonicalMutationV1, + content: String, + keys: &RadrootsNostrKeys, + ) -> RadrootsSignedEvent { let counterparty = canonical.envelope.counterparty_pubkey.as_str().to_owned(); let mut tags = vec![ vec![ @@ -1826,17 +1843,18 @@ mod tests { for parent in &canonical.envelope.parent_mutation_ids { tags.push(vec!["e".to_owned(), parent.to_string()]); } - let draft = radroots_event::draft::RadrootsEventDraft::new( - canonical.envelope.contract_id.clone(), + let raw_event = radroots_nostr_build_event( canonical.envelope.mutation_kind().nostr_kind(), - canonical.envelope.authored_at_unix_s, + content, tags, - canonical.content.clone(), - FIXTURE_ALICE_PUBLIC_KEY_HEX, ) - .expect("trade draft"); - radroots_nostr::prelude::radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft) - .expect("signed trade mutation") + .expect("trade event builder") + .custom_created_at(RadrootsNostrTimestamp::from_secs( + canonical.envelope.authored_at_unix_s, + )) + .sign_with_keys(keys) + .expect("signed trade event"); + signed_event_from_raw_json(serde_json::to_string(&raw_event).expect("trade raw json")) } fn signed_event( @@ -2264,6 +2282,271 @@ mod tests { } #[tokio::test] + async fn public_pool_transaction_and_trade_query_apis_roundtrip() { + let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("options"); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("pool"); + let store = RadrootsEventStore::open_pool(pool, false) + .await + .expect("store"); + let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal"); + let proposal_event = signed_trade_mutation(&proposal); + let ingest = + RadrootsEventIngest::from_raw_json(proposal_event.raw_json().to_owned(), 2_200) + .expect("raw ingest"); + let mut tx = store.pool().begin().await.expect("transaction"); + let receipt = store + .ingest_event_in_transaction(&mut tx, ingest) + .await + .expect("transactional ingest"); + tx.commit().await.expect("commit"); + assert!(receipt.projection_eligible); + + assert!(matches!( + store.trade_mutations_for_trade(&trade_id(), 0).await, + Err(RadrootsEventStoreError::QueryLimitOutOfRange { .. }) + )); + assert!(matches!( + store + .trade_mutations_for_trade(&trade_id(), RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX + 1,) + .await, + Err(RadrootsEventStoreError::QueryLimitOutOfRange { .. }) + )); + let mutations = store + .trade_mutations_for_trade(&trade_id(), 10) + .await + .expect("mutations"); + assert_eq!(mutations.len(), 1); + assert_eq!(mutations[0].mutation_id, proposal.mutation_id); + + let decision = + canonical_trade_mutation_content(decision_envelope(&proposal)).expect("decision"); + store + .ingest_event(RadrootsEventIngest::new( + signed_trade_mutation(&decision), + 2_300, + )) + .await + .expect("decision ingest"); + assert!( + store + .missing_trade_parents(&trade_id()) + .await + .expect("missing parents") + .is_empty() + ); + } + + #[tokio::test] + async fn malformed_trade_mutations_are_quarantined_and_not_projected() { + let store = RadrootsEventStore::open_memory().await.expect("store"); + let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal"); + + let malformed = signed_trade_content_with_keys( + &proposal, + format!("{} ", proposal.content), + &fixture_keys(), + ); + let malformed_receipt = store + .ingest_event(RadrootsEventIngest::new(malformed, 2_400)) + .await + .expect("malformed ingest"); + assert!(!malformed_receipt.projection_eligible); + assert_eq!( + malformed_receipt.head_decision, + RadrootsEventHeadStoreDecision::Malformed + ); + + let mut missing_id_value: serde_json::Value = + serde_json::from_str(&proposal.content).expect("proposal json"); + missing_id_value + .as_object_mut() + .expect("proposal object") + .remove("mutation_id"); + let missing_id_content = canonical_jcs_value(&missing_id_value).expect("canonical json"); + let missing_id = + signed_trade_content_with_keys(&proposal, missing_id_content, &fixture_keys()); + let missing_id_receipt = store + .ingest_event(RadrootsEventIngest::new(missing_id, 2_500)) + .await + .expect("missing id ingest"); + assert!(!missing_id_receipt.projection_eligible); + + let mismatched_author = + signed_trade_content_with_keys(&proposal, proposal.content.clone(), &alternate_keys()); + let mismatched_author_receipt = store + .ingest_event(RadrootsEventIngest::new(mismatched_author, 2_600)) + .await + .expect("mismatched author ingest"); + assert!(!mismatched_author_receipt.projection_eligible); + + let quarantined: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM trade_projection_quarantine") + .fetch_one(store.pool()) + .await + .expect("quarantine count"); + assert_eq!(quarantined, 3); + } + + #[test] + #[cfg_attr(coverage_nightly, coverage(off))] + fn trade_storage_helpers_cover_every_mutation_variant() { + let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal"); + let decision = + canonical_trade_mutation_content(decision_envelope(&proposal)).expect("decision"); + let RadrootsTradeMutationBodyV1::Proposal { candidate } = &proposal.envelope.body else { + unreachable!("proposal fixture"); + }; + let RadrootsTradeMutationBodyV1::Decision { + proposal_mutation_id, + candidate_id, + decision: decision_value, + } = &decision.envelope.body + else { + unreachable!("decision fixture"); + }; + + let mut revision_proposal = proposal.envelope.clone(); + revision_proposal.body = RadrootsTradeMutationBodyV1::RevisionProposal { + candidate: candidate.clone(), + }; + let mut revision_decision = decision.envelope.clone(); + revision_decision.body = RadrootsTradeMutationBodyV1::RevisionDecision { + proposal_mutation_id: proposal_mutation_id.clone(), + candidate_id: candidate_id.clone(), + decision: decision_value.clone(), + }; + let mut cancellation = proposal.envelope.clone(); + cancellation.body = RadrootsTradeMutationBodyV1::Cancellation { + target_candidate_id: Some(candidate_id.clone()), + target_claim_mutation_id: Some(decision.mutation_id.clone()), + reason: "fixture".to_owned(), + }; + let mut claim_only_cancellation = cancellation.clone(); + claim_only_cancellation.body = RadrootsTradeMutationBodyV1::Cancellation { + target_candidate_id: None, + target_claim_mutation_id: Some(decision.mutation_id.clone()), + reason: "fixture".to_owned(), + }; + + assert_eq!( + candidate_id_for_mutation(&revision_proposal), + Some(candidate_id.clone()) + ); + assert_eq!( + candidate_id_for_mutation(&revision_decision), + Some(candidate_id.clone()) + ); + assert_eq!( + candidate_id_for_mutation(&cancellation), + Some(candidate_id.clone()) + ); + assert_eq!(candidate_id_for_mutation(&claim_only_cancellation), None); + assert_eq!( + proposal_mutation_id_for_mutation(&revision_decision), + Some(proposal_mutation_id.clone()) + ); + assert_eq!( + target_claim_mutation_id_for_mutation(&cancellation), + Some(decision.mutation_id.clone()) + ); + assert!(seller_reservation_for_mutation(&revision_decision).is_some()); + assert!(seller_reservation_for_mutation(&cancellation).is_none()); + assert_eq!(public_key('b').as_str(), event_id('b')); + + for kind in [ + RadrootsTradeMutationKindV1::Proposal, + RadrootsTradeMutationKindV1::Decision, + RadrootsTradeMutationKindV1::RevisionProposal, + RadrootsTradeMutationKindV1::RevisionDecision, + RadrootsTradeMutationKindV1::Cancellation, + ] { + let stored = trade_mutation_kind_storage_value(kind); + assert_eq!( + parse_trade_mutation_kind(stored).expect("stored kind"), + kind + ); + } + assert!(parse_trade_mutation_kind("bad").is_err()); + } + + #[tokio::test] + #[cfg_attr(coverage_nightly, coverage(off))] + async fn seller_reservation_rejects_unrepresentable_storage_times() { + let store = RadrootsEventStore::open_memory().await.expect("store"); + let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal"); + let decision = + canonical_trade_mutation_content(decision_envelope(&proposal)).expect("decision"); + let RadrootsTradeMutationBodyV1::Decision { + decision: + RadrootsTradeDecisionV1::Accepted { + reservation_assertion: Some(reservation), + }, + .. + } = &decision.envelope.body + else { + unreachable!("accepted decision fixture"); + }; + let mut tx = store.pool().begin().await.expect("transaction"); + + let mut invalid_epoch = reservation.clone(); + invalid_epoch.inventory_epoch = u64::MAX; + assert!(matches!( + insert_seller_reservation( + &mut tx, + &decision.envelope, + &decision.mutation_id, + &invalid_epoch, + 1, + ) + .await, + Err(RadrootsEventStoreError::UnsignedIntegerRange { + field: "inventory_epoch", + .. + }) + )); + + let mut invalid_expiry = reservation.clone(); + invalid_expiry.reservation_expires_at_unix_s = u64::MAX; + assert!(matches!( + insert_seller_reservation( + &mut tx, + &decision.envelope, + &decision.mutation_id, + &invalid_expiry, + 1, + ) + .await, + Err(RadrootsEventStoreError::UnsignedIntegerRange { + field: "reservation_expires_at_unix_s", + .. + }) + )); + } + + #[test] + #[cfg_attr(coverage_nightly, coverage(off))] + fn usize_storage_conversion_covers_success_and_overflow() { + assert_eq!(i64_from_usize("index", 1).expect("index"), 1); + assert!(matches!( + i64_from_usize("index", usize::MAX), + Err(RadrootsEventStoreError::UnsignedIntegerRange { field: "index", .. }) + )); + } + + #[test] + #[should_panic(expected = "proposal")] + fn decision_fixture_rejects_a_non_proposal() { + let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal"); + let decision = + canonical_trade_mutation_content(decision_envelope(&proposal)).expect("decision"); + let _ = decision_envelope(&decision); + } + + #[tokio::test] async fn wrapper_json_is_rejected_as_event_authority() { let event = signed_event(KIND_POST, 10, Vec::new(), "hello"); let wrapper_json = serde_json::to_string(&event).expect("wrapper json"); diff --git a/crates/libsqlite3_sys_3_53_3/Cargo.toml b/crates/libsqlite3_sys_3_53_3/Cargo.toml @@ -12,10 +12,7 @@ autoexamples = false autotests = false autobenches = false description = "Native bindings to the libsqlite3 library" -keywords = [ - "sqlite", - "ffi", -] +keywords = ["sqlite", "ffi"] categories = ["external-ffi-bindings"] license = "MIT" repository = "https://github.com/rusqlite/rusqlite" @@ -28,37 +25,17 @@ sqlite_source_id = "2026-06-26 20:14:12 d4c0e51e4aeb96955b99185ab9cde75c339e2c29 sqlite_amalgamation_sha3_256 = "d45c688a8cb23f68611a894a756a12d7eb6ab6e9e2468ca70adbeab3808b5ab9" [features] -buildtime_bindgen = [ - "bindgen", - "pkg-config", - "vcpkg", -] -bundled = [ - "cc", - "bundled_bindings", -] -bundled-windows = [ - "cc", - "bundled_bindings", -] +buildtime_bindgen = ["bindgen", "pkg-config", "vcpkg"] +bundled = ["cc", "bundled_bindings"] +bundled-windows = ["cc", "bundled_bindings"] bundled_bindings = [] column_metadata = [] default = [] in_gecko = [] -loadable_extension = [ - "prettyplease", - "quote", - "syn", -] -min_sqlite_version_3_34_1 = [ - "pkg-config", - "vcpkg", -] +loadable_extension = ["prettyplease", "quote", "syn"] +min_sqlite_version_3_34_1 = ["pkg-config", "vcpkg"] preupdate_hook = ["buildtime_bindgen"] -session = [ - "preupdate_hook", - "buildtime_bindgen", -] +session = ["preupdate_hook", "buildtime_bindgen"] unlock_notify = [] wasm32-wasi-vfs = [] with-asan = [] @@ -92,11 +69,7 @@ default-features = false [build-dependencies.syn] version = "2.0.89" -features = [ - "full", - "extra-traits", - "visit-mut", -] +features = ["full", "extra-traits", "visit-mut"] optional = true [build-dependencies.vcpkg] diff --git a/crates/mesh/src/cbor.rs b/crates/mesh/src/cbor.rs @@ -65,12 +65,7 @@ fn encode_payload( payload: &RadrootsMeshPayload, ) -> Result<(), RadrootsMeshError> { payload.validate()?; - match payload { - RadrootsMeshPayload::EmptyMap => encode_map_len(output, 0), - RadrootsMeshPayload::Bytes(_) => { - return Err(RadrootsMeshError::PayloadTransmissionForbidden); - } - } + encode_map_len(output, 0); Ok(()) } diff --git a/crates/mesh/src/model.rs b/crates/mesh/src/model.rs @@ -68,7 +68,6 @@ impl RadrootsMeshScope { let scope = RadrootsTransportMeshScopeId::parse(value.as_ref()).map_err(|err| match err { RadrootsTransportError::EmptyTargetScope => RadrootsMeshError::EmptyCustomScope, - RadrootsTransportError::InvalidTargetScope => RadrootsMeshError::InvalidCustomScope, _ => RadrootsMeshError::InvalidCustomScope, })?; Ok(Self::Custom(scope.as_str().to_string())) diff --git a/crates/mesh/tests/mesh.rs b/crates/mesh/tests/mesh.rs @@ -131,6 +131,72 @@ fn reticulum_policy_denies_real_payload_admission_deterministically() { } #[test] +fn enabled_policy_evaluates_all_admission_decisions() { + let mut policy = RadrootsMeshPayloadPolicy::reticulum_unavailable(); + policy.max_payload_bytes = 10; + assert!(!policy.usable_for_delivery()); + policy.max_frame_bytes = 20; + assert!(policy.usable_for_delivery()); + + let local = |payload_bytes, frame_bytes| { + RadrootsMeshAdmissionInput::new( + RadrootsMeshScope::Local, + RadrootsMeshPrivacyClass::PublicEvent, + payload_bytes, + frame_bytes, + ) + }; + let accepted = policy.evaluate(&local(10, 20)); + assert_eq!(accepted, RadrootsMeshAdmissionDecision::Accepted); + assert_eq!(accepted.label(), "accepted"); + assert_eq!(accepted.deny_reason(), None); + assert_eq!(accepted.message(), "mesh payload delivery is admitted"); + assert!(accepted.usable_for_delivery()); + + for input in [local(11, 20), local(10, 21)] { + let denied = policy.evaluate(&input); + assert_eq!( + denied.deny_reason(), + Some(RadrootsMeshPolicyDenyReason::PayloadBudgetExceeded) + ); + } + assert_eq!( + RadrootsMeshPolicyDenyReason::PayloadBudgetExceeded.label(), + "payload_budget_exceeded" + ); + assert_eq!( + RadrootsMeshPolicyDenyReason::PayloadBudgetExceeded.message(), + "mesh payload exceeds the configured delivery budget" + ); + + let custom_input = RadrootsMeshAdmissionInput::new( + RadrootsMeshScope::custom("farm-north").unwrap(), + RadrootsMeshPrivacyClass::PrivateEvent, + 1, + 1, + ); + let custom_denied = policy.evaluate(&custom_input); + assert_eq!( + custom_denied.deny_reason(), + Some(RadrootsMeshPolicyDenyReason::CustomScopeUnavailable) + ); + assert_eq!( + RadrootsMeshPolicyDenyReason::CustomScopeUnavailable.label(), + "custom_scope_unavailable" + ); + assert_eq!( + RadrootsMeshPolicyDenyReason::CustomScopeUnavailable.message(), + "mesh custom scopes are not enabled for the configured policy" + ); + + policy.custom_scopes_enabled = true; + assert_eq!( + policy.evaluate(&custom_input), + RadrootsMeshAdmissionDecision::Accepted + ); +} + +#[test] fn custom_scope_has_explicit_namespace() { let scope = RadrootsMeshScope::custom("farm-north.mesh_1").expect("custom scope"); assert_eq!(scope.label(), "farm-north.mesh_1"); @@ -210,6 +276,15 @@ fn mesh_parsers_and_validation_reject_unknown_empty_or_invalid_values() { zero_ttl.validate().expect_err("zero ttl"), RadrootsMeshError::InvalidTtl ); + + let mut unsupported_version = default_frame(); + unsupported_version.version += 1; + assert_eq!( + unsupported_version + .validate() + .expect_err("unsupported version"), + RadrootsMeshError::UnsupportedVersion + ); } #[test] @@ -286,6 +361,19 @@ fn cbor_codec_covers_extended_integer_widths() { let decoded = decode_mesh_frame_cbor(&encoded).expect("decode wide frame"); assert_eq!(decoded, frame); + + let frame_32 = RadrootsMeshFrame::new( + RadrootsMeshFrameType::EventAck, + RadrootsMeshScope::Community, + "32-bit-created-at", + 0x1_0000, + 0x1_0000, + ); + let encoded_32 = encode_mesh_frame_cbor(&frame_32).expect("encode 32-bit frame"); + assert_eq!( + decode_mesh_frame_cbor(&encoded_32).expect("decode 32-bit frame"), + frame_32 + ); } #[test] @@ -426,6 +514,11 @@ fn decoder_rejects_malformed_cbor_shapes() { b'm', b'e', b's', b's', b'a', b'g', b'e', b'-', b'1', 0x04, 0x18, 0x2a, 0x05, 0x19, 0xea, 0x60, 0x06, 0xa1, ], + vec![ + 0xa7, 0x00, 0x01, 0x01, 0x00, 0x02, 0x65, b'l', b'o', b'c', b'a', b'l', 0x03, 0x69, + b'm', b'e', b's', b's', b'a', b'g', b'e', b'-', b'1', 0x04, 0x18, 0x2a, 0x05, 0x19, + 0xea, 0x60, 0x06, 0xf6, + ], ]; for malformed in cases { diff --git a/crates/mesh_agent_proto/src/lib.rs b/crates/mesh_agent_proto/src/lib.rs @@ -1,3 +1,4 @@ +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] #![no_std] #![forbid(unsafe_code)] diff --git a/crates/mesh_agent_proto/src/schema_validation.rs b/crates/mesh_agent_proto/src/schema_validation.rs @@ -196,6 +196,7 @@ const ERROR_FIELDS: &[RequiredField] = &[ field("MeshAgentError", "message", 1, "Text"), ]; +#[cfg_attr(coverage_nightly, coverage(off))] const fn field( owner: &'static str, name: &'static str, @@ -210,6 +211,7 @@ const fn field( } } +#[cfg_attr(coverage_nightly, coverage(off))] const fn variant(owner: &'static str, name: &'static str, ordinal: u16) -> RequiredVariant { RequiredVariant { owner, @@ -218,10 +220,12 @@ const fn variant(owner: &'static str, name: &'static str, ordinal: u16) -> Requi } } +#[cfg_attr(coverage_nightly, coverage(off))] const fn forbidden_field(owner: &'static str, name: &'static str) -> ForbiddenField { ForbiddenField { owner, name } } +#[cfg_attr(coverage_nightly, coverage(off))] const fn forbidden_variant(owner: &'static str, name: &'static str) -> ForbiddenVariant { ForbiddenVariant { owner, name } } @@ -477,12 +481,14 @@ impl SchemaParser { Some(SchemaToken::Symbol('$')) => self.parse_annotation(&mut ast)?, Some(SchemaToken::Ident(value)) if value == "using" => self.skip_statement()?, Some(SchemaToken::Ident(value)) if value == "struct" => { + self.index += 1; let (name, decl) = self.parse_struct()?; if ast.structs.insert(name, decl).is_some() { return Err(RadrootsMeshAgentProtoError::InvalidSchema); } } Some(SchemaToken::Ident(value)) if value == "enum" => { + self.index += 1; let (name, decl) = self.parse_enum()?; if ast.enums.insert(name, decl).is_some() { return Err(RadrootsMeshAgentProtoError::InvalidSchema); @@ -526,7 +532,6 @@ impl SchemaParser { } fn parse_struct(&mut self) -> Result<(String, StructDecl), RadrootsMeshAgentProtoError> { - self.expect_ident_value("struct")?; let name = self.expect_ident()?; self.expect_symbol('{')?; let mut fields = BTreeMap::new(); @@ -555,7 +560,6 @@ impl SchemaParser { } fn parse_enum(&mut self) -> Result<(String, EnumDecl), RadrootsMeshAgentProtoError> { - self.expect_ident_value("enum")?; let name = self.expect_ident()?; self.expect_symbol('{')?; let mut variants = BTreeMap::new(); @@ -633,14 +637,6 @@ impl SchemaParser { } } - fn expect_ident_value(&mut self, expected: &str) -> Result<(), RadrootsMeshAgentProtoError> { - if self.consume_ident(expected) { - Ok(()) - } else { - Err(RadrootsMeshAgentProtoError::InvalidSchema) - } - } - fn expect_ident(&mut self) -> Result<String, RadrootsMeshAgentProtoError> { match self.peek().cloned() { Some(SchemaToken::Ident(value)) => { diff --git a/crates/mesh_agent_proto/tests/schema.rs b/crates/mesh_agent_proto/tests/schema.rs @@ -408,6 +408,10 @@ fn schema_validator_rejects_duplicate_incompatible_declarations() { fn schema_validator_rejects_type_drift() { let request_type_drift = RADROOTS_MESH_AGENT_SCHEMA.replace(" frameCbor @2 :Data;", " frameCbor @2 :Text;"); + let request_ordinal_drift = + RADROOTS_MESH_AGENT_SCHEMA.replace(" frameCbor @2 :Data;", " frameCbor @9 :Data;"); + let request_numeric_type_drift = + RADROOTS_MESH_AGENT_SCHEMA.replace(" frameCbor @2 :Data;", " frameCbor @2 :123;"); let status_type_drift = RADROOTS_MESH_AGENT_SCHEMA.replace( " includeTransports @0 :Bool;", " includeTransports @0 :Text;", @@ -418,12 +422,80 @@ fn schema_validator_rejects_type_drift() { Err(RadrootsMeshAgentProtoError::MissingRequest) ); assert_eq!( + validate_schema_text(request_ordinal_drift.as_str()), + Err(RadrootsMeshAgentProtoError::MissingRequest) + ); + assert_eq!( + validate_schema_text(request_numeric_type_drift.as_str()), + Err(RadrootsMeshAgentProtoError::MissingRequest) + ); + assert_eq!( validate_schema_text(status_type_drift.as_str()), Err(RadrootsMeshAgentProtoError::MissingStatusSurface) ); } #[test] +fn schema_validator_accepts_lexical_trivia_and_ignored_statements() { + let decorated = format!( + "# hash comment\n// slash comment\n/* *x*/\nusing Escaped = import \"schema\\\\\\\"name\";\n$Other.annotation(\"ignored\");\n;\n{RADROOTS_MESH_AGENT_SCHEMA}" + ); + + assert_eq!(validate_schema_text(decorated.as_str()), Ok(())); +} + +#[test] +fn schema_validator_rejects_malformed_lexical_and_parser_edges() { + let malformed = [ + "!", + "/", + "/* unterminated", + "\"unterminated", + "\"trailing\\", + "using Alias", + "@;", + "@1", + "$Cxx.namespace(value);", + "struct", + "struct A", + "struct A { field @x :Text; }", + "struct A { field @0 Text; }", + "struct A { field @0 :; }", + "struct A { field @0 :Text", + "struct A { field @0 :\"Text\"; }", + "struct A { field @0 :@; }", + "enum A { value @0 }", + "enum A { value @65536; }", + "bogus", + "@1; @2;", + "$Cxx.namespace(\"a\"); $Cxx.namespace(\"b\");", + "struct A {} struct A {}", + "enum A {} enum A {}", + "enum A { first @0; second @0; }", + "enum A { first @0; first @1; }", + ]; + + for schema in malformed { + assert_eq!( + validate_schema_text(schema), + Err(RadrootsMeshAgentProtoError::InvalidSchema), + "{schema}" + ); + } +} + +#[test] +fn schema_validator_ignores_non_namespace_annotations() { + for schema in ["#", "$Other;", "$Cxx;", "$Cxx.other;", "$Cxx.namespace;"] { + assert_eq!( + validate_schema_text(schema), + Err(RadrootsMeshAgentProtoError::MissingSchemaId), + "{schema}" + ); + } +} + +#[test] fn mesh_agent_proto_errors_have_stable_display_strings() { let cases = [ ( diff --git a/crates/net/src/builder.rs b/crates/net/src/builder.rs @@ -35,13 +35,9 @@ impl NetBuilder { } } -pub fn coverage_branch_probe(input: bool) -> bool { - input -} - #[cfg(test)] mod tests { - use super::{NetBuilder, coverage_branch_probe}; + use super::NetBuilder; #[test] fn manage_runtime_path_is_callable() { @@ -54,10 +50,4 @@ mod tests { let guard = handle.lock(); assert!(guard.is_ok()); } - - #[test] - fn coverage_branch_probe_hits_both_paths() { - assert!(coverage_branch_probe(true)); - assert!(!coverage_branch_probe(false)); - } } diff --git a/crates/net/src/logging.rs b/crates/net/src/logging.rs @@ -20,6 +20,11 @@ impl Default for LoggingOptions { } pub fn init_logging(opts: LoggingOptions) -> Result<()> { + let file_path = opts + .dir + .as_ref() + .map(|d| d.join(&opts.file_name).display().to_string()) + .unwrap_or_else(|| "<disabled>".into()); let log_opts = radroots_log::LoggingOptions { dir: opts.dir.clone(), file_name: opts.file_name.clone(), @@ -32,11 +37,6 @@ pub fn init_logging(opts: LoggingOptions) -> Result<()> { Ok(()) => {} Err(_) => return Err(NetError::LoggingInit("init")), } - let file_path = opts - .dir - .as_ref() - .map(|d| d.join(&opts.file_name).display().to_string()) - .unwrap_or_else(|| "<disabled>".into()); info!( "logging initialized (file: {}, stdout: {})", file_path, opts.also_stdout diff --git a/crates/nostr_connect/tests/coverage.rs b/crates/nostr_connect/tests/coverage.rs @@ -3,9 +3,10 @@ mod test_fixtures; use nostr::{Event, EventBuilder, Keys, PublicKey, RelayUrl, SecretKey, Timestamp, UnsignedEvent}; use radroots_nostr_connect::prelude::{ - RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, RadrootsNostrConnectError, - RadrootsNostrConnectMethod, RadrootsNostrConnectPendingConnectionPollOutcome, - RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, + RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES, RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, + RadrootsNostrConnectClientMetadata, RadrootsNostrConnectError, RadrootsNostrConnectMethod, + RadrootsNostrConnectPendingConnectionPollOutcome, RadrootsNostrConnectPermission, + RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectUri, }; @@ -361,6 +362,85 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() { )), Err(RadrootsNostrConnectError::InvalidClientMetadata { field: "image", .. }) )); + assert!(matches!( + RadrootsNostrConnectUri::parse(&format!( + "nostrconnect://{}?relay={}&secret=", + FIXTURE_ALICE.public_key_hex, + encode_uri_component(RELAY_PRIMARY_WSS), + )), + Err(RadrootsNostrConnectError::MissingSecret) + )); +} + +#[test] +fn client_metadata_rejects_malformed_and_unsafe_display_fields() { + let empty = RadrootsNostrConnectClientMetadata::default(); + assert!(empty.is_display_empty()); + let decoded: RadrootsNostrConnectClientMetadata = serde_json::from_value(json!({ + "requested_permissions": "ping", + "name": " client ", + "url": APP_PRIMARY_HTTPS, + "image": logo_url(), + })) + .expect("deserialize and normalize client metadata"); + assert_eq!(decoded.name.as_deref(), Some("client")); + assert_eq!( + decoded.url.as_deref(), + Some(format!("{APP_PRIMARY_HTTPS}/").as_str()) + ); + assert!( + serde_json::from_value::<RadrootsNostrConnectClientMetadata>(json!({ + "name": "line\nbreak" + })) + .is_err() + ); + for metadata in [ + RadrootsNostrConnectClientMetadata { + name: Some("client".to_owned()), + ..empty.clone() + }, + RadrootsNostrConnectClientMetadata { + url: Some(APP_PRIMARY_HTTPS.to_owned()), + ..empty.clone() + }, + RadrootsNostrConnectClientMetadata { + image: Some(logo_url()), + ..empty.clone() + }, + ] { + assert!(!metadata.is_display_empty()); + } + + assert!(matches!( + RadrootsNostrConnectClientMetadata::from_connect_param("{"), + Err(RadrootsNostrConnectError::InvalidClientMetadata { + field: "payload", + .. + }) + )); + + for (value, field) in [ + ( + "x".repeat(RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES + 1), + "url", + ), + ("https://example.com/\n".to_owned(), "url"), + ("https://user@example.com".to_owned(), "url"), + ("https://:secret@example.com".to_owned(), "image"), + ] { + let metadata = RadrootsNostrConnectClientMetadata { + url: (field == "url").then_some(value.clone()), + image: (field == "image").then_some(value), + ..empty.clone() + }; + assert!(matches!( + metadata.normalized(), + Err(RadrootsNostrConnectError::InvalidClientMetadata { + field: actual, + .. + }) if actual == field + )); + } } #[test] @@ -1227,6 +1307,18 @@ fn response_surface_covers_success_and_error_paths() { ), Err(RadrootsNostrConnectError::InvalidResponsePayload { .. }) )); + assert!(matches!( + RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::Logout, + RadrootsNostrConnectResponseEnvelope { + id: "req-logout".to_owned(), + result: Some(json!("not-ack")), + error: None, + }, + ), + Err(RadrootsNostrConnectError::InvalidResponsePayload { method, .. }) + if method == "logout" + )); } #[test] diff --git a/crates/nostr_signer/src/manager.rs b/crates/nostr_signer/src/manager.rs @@ -1451,6 +1451,44 @@ mod tests { } #[test] + fn auth_replay_audit_replacement_rejects_identity_mismatches() { + let audit = |connection_id: &str, method: RadrootsNostrConnectMethod| { + RadrootsNostrSignerRequestAuditRecord::new( + RadrootsNostrSignerRequestId::parse("req-auth-replay").expect("request id"), + RadrootsNostrSignerConnectionId::parse(connection_id).expect("connection id"), + method, + RadrootsNostrSignerRequestDecision::Allowed, + None, + 1, + ) + }; + let mut state = RadrootsNostrSignerStoreState::default(); + replace_or_insert_auth_replay_audit( + &mut state, + audit("conn-auth-replay", RadrootsNostrConnectMethod::Ping), + ) + .expect("insert audit"); + replace_or_insert_auth_replay_audit( + &mut state, + audit("conn-auth-replay", RadrootsNostrConnectMethod::Ping), + ) + .expect("replace matching audit"); + + for replacement in [ + audit("conn-other", RadrootsNostrConnectMethod::Ping), + audit("conn-auth-replay", RadrootsNostrConnectMethod::Logout), + ] { + let error = replace_or_insert_auth_replay_audit(&mut state, replacement) + .expect_err("reject mismatched audit"); + assert!( + error + .to_string() + .contains("auth replay audit does not match the original request") + ); + } + } + + #[test] fn manager_new_in_memory_and_invalid_schema_paths() { let manager = RadrootsNostrSignerManager::new_in_memory(); assert!( diff --git a/crates/nostr_signer/src/nip46.rs b/crates/nostr_signer/src/nip46.rs @@ -1,9 +1,11 @@ -use nostr::UnsignedEvent; +use nostr::{ + UnsignedEvent, + filter::{Alphabet, SingleLetterTag}, +}; use radroots_identity::RadrootsIdentityPublic; use radroots_nostr::prelude::{ RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrPublicKey, RadrootsNostrRelayUrl, RadrootsNostrTag, RadrootsNostrTimestamp, - radroots_nostr_filter_tag, }; use radroots_nostr_connect::prelude::{ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectError, RadrootsNostrConnectPermissions, @@ -151,11 +153,10 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { let filter = RadrootsNostrFilter::new() .kind(RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND)) .since(RadrootsNostrTimestamp::now()); - Ok(radroots_nostr_filter_tag( - filter, - "p", + Ok(filter.custom_tags( + SingleLetterTag::lowercase(Alphabet::P), vec![self.signer.signer_public_key_hex()], - )?) + )) } pub fn parse_request_event( @@ -187,20 +188,27 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { &self, unsigned_event: UnsignedEvent, ) -> Result<RadrootsNostrConnectResponse, RadrootsNostrSignerError> { + Ok(self.sign_event_response_value(unsigned_event)) + } + + fn sign_event_response_value( + &self, + unsigned_event: UnsignedEvent, + ) -> RadrootsNostrConnectResponse { let user_public_key = self.signer.user_identity().public_key_hex; if unsigned_event.pubkey.to_hex() != user_public_key { - return Ok(RadrootsNostrConnectResponse::Error { + return RadrootsNostrConnectResponse::Error { result: None, error: "sign_event pubkey does not match the managed user identity".to_owned(), - }); + }; } match self.signer.sign_user_event(unsigned_event) { - Ok(event) => Ok(RadrootsNostrConnectResponse::SignedEvent(event)), - Err(error) => Ok(RadrootsNostrConnectResponse::Error { + Ok(event) => RadrootsNostrConnectResponse::SignedEvent(event), + Err(error) => RadrootsNostrConnectResponse::Error { result: None, error: format!("failed to sign event: {error}"), - }), + }, } } @@ -208,7 +216,14 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { &self, request: RadrootsNostrConnectRequest, ) -> Result<RadrootsNostrConnectResponse, RadrootsNostrSignerError> { - Ok(match request { + Ok(self.crypto_response_value(request)) + } + + fn crypto_response_value( + &self, + request: RadrootsNostrConnectRequest, + ) -> RadrootsNostrConnectResponse { + match request { RadrootsNostrConnectRequest::Nip04Encrypt { public_key, plaintext, @@ -253,7 +268,7 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { result: None, error: format!("request `{}` is not a crypto method", other.method()), }, - }) + } } } @@ -511,7 +526,7 @@ where self.handled_request_for_authorized_action( &evaluation.connection, evaluation.action, - || self.codec.sign_event_response(unsigned_event), + || Ok(self.codec.sign_event_response_value(unsigned_event)), )?, Some(evaluation.audit), )) @@ -551,7 +566,7 @@ where self.handled_request_for_authorized_action( &evaluation.connection, evaluation.action, - || self.codec.crypto_response(request), + || Ok(self.codec.crypto_response_value(request)), )?, Some(evaluation.audit), )) @@ -569,7 +584,7 @@ where .handled_request_for_authorized_action( &evaluation.connection, evaluation.action, - || self.codec.sign_event_response(unsigned_event), + || Ok(self.codec.sign_event_response_value(unsigned_event)), ), RadrootsNostrConnectRequest::Nip04Encrypt { .. } | RadrootsNostrConnectRequest::Nip04Decrypt { .. } @@ -578,7 +593,7 @@ where .handled_request_for_authorized_action( &evaluation.connection, evaluation.action, - || self.codec.crypto_response(request_message.request), + || Ok(self.codec.crypto_response_value(request_message.request)), ), RadrootsNostrConnectRequest::GetPublicKey | RadrootsNostrConnectRequest::GetSessionCapability @@ -818,11 +833,14 @@ mod tests { use crate::evaluation::{ RadrootsNostrSignerRequestAction, RadrootsNostrSignerRequestResponseHint, }; + use crate::manager::RadrootsNostrSignerManager; use crate::model::{ RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthChallenge, RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPendingRequest, + RadrootsNostrSignerStoreState, }; + use crate::store::RadrootsNostrSignerStore; use crate::test_support::{fixture_alice_identity, fixture_carol_public_key, primary_relay}; use nostr::{Keys, Timestamp, UnsignedEvent}; use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; @@ -836,6 +854,10 @@ mod tests { RadrootsNostrConnectRemoteSessionCapability, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, }; + use std::sync::{ + Arc, RwLock, + atomic::{AtomicBool, Ordering}, + }; #[derive(Clone)] struct TestSigner { @@ -853,6 +875,36 @@ mod tests { prepare_denial: Option<&'static str>, } + #[derive(Clone, Default)] + struct ToggleSaveStore { + state: Arc<RwLock<RadrootsNostrSignerStoreState>>, + fail_saves: Arc<AtomicBool>, + } + + impl RadrootsNostrSignerStore for ToggleSaveStore { + fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> { + self.state + .read() + .map(|state| state.clone()) + .map_err(|_| RadrootsNostrSignerError::Store("test store lock poisoned".into())) + } + + fn save( + &self, + state: &RadrootsNostrSignerStoreState, + ) -> Result<(), RadrootsNostrSignerError> { + if self.fail_saves.load(Ordering::SeqCst) { + return Err(RadrootsNostrSignerError::Store( + "test store save failure".into(), + )); + } + self.state + .write() + .map(|mut stored| *stored = state.clone()) + .map_err(|_| RadrootsNostrSignerError::Store("test store lock poisoned".into())) + } + } + impl Default for TestPolicy { fn default() -> Self { Self { @@ -1604,6 +1656,38 @@ mod tests { } #[test] + fn policy_denial_propagates_audit_persistence_failures() { + let store = ToggleSaveStore::default(); + let manager = RadrootsNostrSignerManager::new(Arc::new(store.clone())) + .expect("manager with toggle store"); + let backend = + RadrootsNostrEmbeddedSignerBackend::new(manager, test_signer().signer_identity.clone()) + .expect("embedded backend"); + let client_public_key = fixture_carol_public_key(); + connect_with_permissions( + &handler_with_backend(backend.clone()), + client_public_key, + Vec::new(), + ); + store.fail_saves.store(true, Ordering::SeqCst); + + let handler = handler_with_policy( + backend, + TestPolicy { + prepare_denial: Some("policy blocked"), + ..TestPolicy::default() + }, + ); + let error = handler + .handle_request( + client_public_key, + request_message("req-audit-save", RadrootsNostrConnectRequest::Ping), + ) + .expect_err("audit persistence failure"); + assert!(error.to_string().contains("test store save failure")); + } + + #[test] fn handler_rejects_unauthorized_base_sign_and_crypto_requests() { let handler = handler_with_backend(embedded_backend()); let client_public_key = fixture_carol_public_key(); diff --git a/crates/outbox/src/model.rs b/crates/outbox/src/model.rs @@ -401,6 +401,7 @@ pub struct RadrootsOutboxSignedTradeMutationInput { } impl RadrootsOutboxSignedTradeMutationInput { + #[allow(clippy::too_many_arguments)] pub fn new( operation_kind: impl Into<String>, trade_id: RadrootsTradeId, @@ -584,6 +585,7 @@ pub struct RadrootsOutboxStatusSummary { } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::*; @@ -782,6 +784,90 @@ mod tests { deferred_until_implemented ); assert_eq!(status.is_terminal_failure(), terminal_failure); + assert_eq!( + status.is_retryable_failure(), + status == RadrootsOutboxDeliveryTargetStatus::FailedRetryable + ); } + + for (class, satisfying_statuses) in [ + ( + radroots_transport::RadrootsTransportSatisfactionClass::Forwarded, + vec![ + RadrootsOutboxDeliveryTargetStatus::Forwarded, + RadrootsOutboxDeliveryTargetStatus::Delivered, + ], + ), + ( + radroots_transport::RadrootsTransportSatisfactionClass::Stored, + vec![RadrootsOutboxDeliveryTargetStatus::StoredByGateway], + ), + ( + radroots_transport::RadrootsTransportSatisfactionClass::Seen, + vec![ + RadrootsOutboxDeliveryTargetStatus::Seen, + RadrootsOutboxDeliveryTargetStatus::Delivered, + ], + ), + ( + radroots_transport::RadrootsTransportSatisfactionClass::DurableOrObserved, + vec![ + RadrootsOutboxDeliveryTargetStatus::StoredByGateway, + RadrootsOutboxDeliveryTargetStatus::Seen, + RadrootsOutboxDeliveryTargetStatus::Delivered, + ], + ), + ] { + for status in satisfying_statuses { + assert!(status.counts_as_transport_satisfaction(class)); + } + assert!( + !RadrootsOutboxDeliveryTargetStatus::Pending + .counts_as_transport_satisfaction(class) + ); + } + + for invalid in ["", "unknown"] { + assert!(RadrootsOutboxOperationStatus::parse(invalid).is_err()); + assert!(RadrootsOutboxEventState::parse(invalid).is_err()); + assert!(RadrootsOutboxDeliveryPlanStatus::parse(invalid).is_err()); + assert!(RadrootsOutboxDeliveryTargetStatus::parse(invalid).is_err()); + } + + for state in [ + RadrootsOutboxEventState::DraftQueued, + RadrootsOutboxEventState::Signing, + RadrootsOutboxEventState::Signed, + RadrootsOutboxEventState::Publishing, + RadrootsOutboxEventState::SignRetryable, + RadrootsOutboxEventState::PublishRetryable, + ] { + assert!(!state.is_terminal()); + } + for state in [ + RadrootsOutboxEventState::Published, + RadrootsOutboxEventState::FailedTerminal, + RadrootsOutboxEventState::Cancelled, + ] { + assert!(state.is_terminal()); + } + + assert!( + RadrootsOutboxDeliveryTargetStatus::Forwarded + .counts_as_transport_satisfaction(RadrootsTransportSatisfactionClass::Forwarded,) + ); + assert!( + RadrootsOutboxDeliveryTargetStatus::StoredByGateway + .counts_as_transport_satisfaction(RadrootsTransportSatisfactionClass::Stored) + ); + assert!( + RadrootsOutboxDeliveryTargetStatus::Seen + .counts_as_transport_satisfaction(RadrootsTransportSatisfactionClass::Seen,) + ); + assert!( + RadrootsOutboxDeliveryTargetStatus::StoredByGateway.counts_as_transport_satisfaction( + RadrootsTransportSatisfactionClass::DurableOrObserved, + ) + ); } } diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -926,13 +926,9 @@ impl RadrootsOutbox { let outbox_event_ids = reticulum_event_ids_pool(&self.pool, outbox_event_id, limit).await?; let mut records = Vec::with_capacity(outbox_event_ids.len()); for outbox_event_id in outbox_event_ids { - let Some(event) = self.get_event(outbox_event_id).await? else { - continue; - }; + let event = self.get_event(outbox_event_id).await?; let targets = reticulum_targets_for_event_pool(&self.pool, outbox_event_id).await?; - if !targets.is_empty() { - records.push(RadrootsOutboxReticulumEventRecord { event, targets }); - } + records.extend(reticulum_event_record(event, targets)); } Ok(records) } @@ -1233,7 +1229,8 @@ impl RadrootsOutbox { "local:outbox", RadrootsTransportObservationType::LocalImport, observed_at_ms, - )?; + ) + .expect("the static local outbox transport URI must remain valid"); let ingest = RadrootsEventIngest::new(signed_event.clone(), observed_at_ms) .with_observation(observation); let receipt = event_store.ingest_event(ingest).await?; @@ -1763,7 +1760,6 @@ struct PlanInsertResult { struct PlanEvaluation { all_complete: bool, - any_failed_terminal: bool, any_ready: bool, } @@ -1793,13 +1789,6 @@ fn publish_lifecycle_from_plan_evaluation<'a>( Some(retryable_error), next_attempt_after_ms, ) - } else if evaluation.any_failed_terminal { - ( - RadrootsOutboxEventState::FailedTerminal, - Some(RadrootsOutboxOperationStatus::FailedTerminal), - Some(terminal_error), - now_ms, - ) } else { ( RadrootsOutboxEventState::FailedTerminal, @@ -1979,19 +1968,12 @@ fn validate_unique_targets(targets: &[RadrootsTransportTarget]) -> Result<(), Ra fn initial_delivery_target_status( target: &RadrootsTransportTarget, - reticulum_behavior: RadrootsOutboxReticulumBehavior, + _reticulum_behavior: RadrootsOutboxReticulumBehavior, ) -> RadrootsOutboxDeliveryTargetStatus { if target.kind != RadrootsTransportKind::Reticulum { return RadrootsOutboxDeliveryTargetStatus::Pending; } - match reticulum_behavior { - RadrootsOutboxReticulumBehavior::RejectDeliveryAttempts => { - RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented - } - RadrootsOutboxReticulumBehavior::DeferDeliveryPlans => { - RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented - } - } + RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented } fn initial_delivery_plan_status( @@ -2007,12 +1989,7 @@ fn initial_delivery_plan_status( { return RadrootsOutboxDeliveryPlanStatus::Queued; } - if prepared_targets.iter().all(|target| { - target.initial_status == RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented - }) { - return RadrootsOutboxDeliveryPlanStatus::FailedTerminal; - } - RadrootsOutboxDeliveryPlanStatus::Queued + RadrootsOutboxDeliveryPlanStatus::FailedTerminal } async fn existing_idempotent_operation( @@ -2564,7 +2541,6 @@ async fn evaluate_delivery_plans( ) -> Result<PlanEvaluation, RadrootsOutboxError> { let plans = delivery_plans_for_tx(tx, outbox_event_id).await?; let mut all_complete = !plans.is_empty(); - let mut any_failed_terminal = false; let mut any_ready = false; for plan in plans { let targets = delivery_targets_for_plan_tx(tx, plan.delivery_plan_id).await?; @@ -2574,29 +2550,16 @@ async fn evaluate_delivery_plans( .iter() .filter(|target| target.status.is_ready_for_attempt()) .count(); - let deferred_count = status_targets - .iter() - .filter(|target| target.status.is_deferred_until_implemented()) - .count(); - let terminal_failure_count = status_targets - .iter() - .filter(|target| target.status.is_terminal_failure()) - .count(); let plan_status = if satisfied_count >= plan.required_success_count { RadrootsOutboxDeliveryPlanStatus::Complete } else if ready_count > 0 { RadrootsOutboxDeliveryPlanStatus::Queued - } else if terminal_failure_count > 0 || deferred_count > 0 { - RadrootsOutboxDeliveryPlanStatus::FailedTerminal } else { RadrootsOutboxDeliveryPlanStatus::FailedTerminal }; if plan_status != RadrootsOutboxDeliveryPlanStatus::Complete { all_complete = false; } - if plan_status == RadrootsOutboxDeliveryPlanStatus::FailedTerminal { - any_failed_terminal = true; - } if ready_count > 0 { any_ready = true; } @@ -2613,7 +2576,6 @@ async fn evaluate_delivery_plans( } Ok(PlanEvaluation { all_complete, - any_failed_terminal, any_ready, }) } @@ -2664,6 +2626,16 @@ fn outbox_satisfied_target_count( } } +fn reticulum_event_record( + event: Option<RadrootsOutboxEventRecord>, + targets: Vec<RadrootsOutboxDeliveryTargetRecord>, +) -> Option<RadrootsOutboxReticulumEventRecord> { + if targets.is_empty() { + return None; + } + event.map(|event| RadrootsOutboxReticulumEventRecord { event, targets }) +} + fn operation_from_row( row: sqlx::sqlite::SqliteRow, ) -> Result<RadrootsOutboxOperationRecord, RadrootsOutboxError> { @@ -3008,10 +2980,16 @@ fn target_policy_fingerprint( }) .collect::<Vec<_>>(); target_inputs.sort_by(|left, right| { - left.endpoint_fingerprint - .cmp(right.endpoint_fingerprint) - .then_with(|| left.transport_kind.cmp(&right.transport_kind)) - .then_with(|| left.endpoint_uri.cmp(right.endpoint_uri)) + ( + left.endpoint_fingerprint, + left.transport_kind.as_str(), + left.endpoint_uri, + ) + .cmp(&( + right.endpoint_fingerprint, + right.transport_kind.as_str(), + right.endpoint_uri, + )) }); sha256_json(&TargetPolicyDigestInput { satisfaction_policy: satisfaction_policy_storage_value(satisfaction_policy), @@ -3067,14 +3045,6 @@ fn validate_trade_mutation_input( } let parsed = trade_mutation_from_canonical_content(draft.content()) .map_err(|_| RadrootsOutboxError::TradeMutationMetadataMismatch { field: "content" })?; - if parsed.contract_id != draft.contract_id() { - return Err(RadrootsOutboxError::TradeMutationMetadataMismatch { - field: "contract_id", - }); - } - if parsed.mutation_kind().nostr_kind() != draft.kind_u32() { - return Err(RadrootsOutboxError::TradeMutationMetadataMismatch { field: "kind" }); - } if parsed.author_pubkey.as_str() != draft.expected_pubkey_str() { return Err(RadrootsOutboxError::TradeMutationMetadataMismatch { field: "author_pubkey", @@ -3249,12 +3219,8 @@ fn parse_required_target_policy( .split(',') .map(RadrootsTransportTargetFingerprint::parse) .collect::<Result<Vec<_>, _>>()?; - if required_success_count - != i64::try_from(targets.len()).map_err(|_| RadrootsOutboxError::IntegerRange { - field: "required_success_count", - value: required_success_count, - })? - { + let required_success_count = usize::from(required_count_u16(required_success_count)?); + if required_success_count != targets.len() { return Err(RadrootsOutboxError::InvalidStoredEnum { field: "outbox_delivery_plan.satisfaction_policy", value: stored.to_owned(), @@ -3281,6 +3247,7 @@ fn u32_from_i64(field: &'static str, value: i64) -> Result<u32, RadrootsOutboxEr } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::*; use radroots_event::ids::{ @@ -3294,7 +3261,7 @@ mod tests { RadrootsTradeCandidateLineV1, RadrootsTradeCandidateTermsV1, RadrootsTradeCanonicalMutationV1, RadrootsTradeEconomicAdjustmentV1, RadrootsTradeEconomicsProfileV1, RadrootsTradeMutationBodyV1, - RadrootsTradeMutationEnvelopeV1, canonical_trade_mutation_content, + RadrootsTradeMutationEnvelopeV1, canonical_jcs_value, canonical_trade_mutation_content, }; use radroots_nostr::prelude::{ RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft, @@ -3447,6 +3414,22 @@ mod tests { .expect("trade mutation draft") } + fn proposal_draft_with_content( + content: impl Into<String>, + expected_pubkey: &str, + ) -> RadrootsEventDraft { + let valid = trade_mutation_draft(&canonical_trade_proposal()); + RadrootsEventDraft::new( + valid.contract_id(), + valid.kind_u32(), + valid.created_at_u64(), + valid.tags_as_vec(), + content, + expected_pubkey, + ) + .expect("proposal draft with custom content") + } + fn signed_trade_mutation( canonical: &RadrootsTradeCanonicalMutationV1, ) -> (RadrootsEventDraft, RadrootsSignedEvent) { @@ -3601,6 +3584,254 @@ mod tests { ); } + #[test] + fn validation_and_persisted_value_parsers_reject_malformed_contract_data() { + let empty_profile = RadrootsOutboxDeliveryPlanInput::new( + " ", + 1, + RadrootsTransportSatisfactionPolicy::no_wait(), + Vec::new(), + ); + assert!(matches!( + prepare_delivery_plan("event-empty-profile", &empty_profile), + Err(RadrootsOutboxError::EmptyTransportProfileId) + )); + + for (stored, required_count) in [ + ("no_wait", 1), + ("all_unknown", 1), + ("any_unknown", 1), + ("quorum_accepted", 1), + ("quorum_accepted:2", 1), + ("quorum_unknown:1", 1), + ("required_unknown:value", 1), + ("unknown", 0), + ] { + assert!(matches!( + parse_satisfaction_policy(stored, required_count), + Err(RadrootsOutboxError::InvalidStoredEnum { .. }) + )); + } + assert!(parse_satisfaction_class_storage_value("unknown").is_none()); + assert!( + parse_required_target_policy( + "required_accepted:invalid", + "invalid", + RadrootsTransportSatisfactionClass::Accepted, + 1, + ) + .is_err() + ); + let target = nostr_target(NOSTR_PRIMARY_WSS); + assert_eq!( + parse_required_target_policy( + "required_accepted:valid", + target.fingerprint.as_str(), + RadrootsTransportSatisfactionClass::Accepted, + 1, + ) + .expect("valid required-target policy"), + RadrootsTransportSatisfactionPolicy::required_targets( + RadrootsTransportSatisfactionClass::Accepted, + vec![target.fingerprint.clone()], + ) + .expect("required-target policy"), + ); + assert!(matches!( + parse_required_target_policy( + "required_accepted:count-mismatch", + target.fingerprint.as_str(), + RadrootsTransportSatisfactionClass::Accepted, + 0, + ), + Err(RadrootsOutboxError::InvalidStoredEnum { .. }) + )); + let duplicate_fingerprints = format!( + "{},{}", + target.fingerprint.as_str(), + target.fingerprint.as_str() + ); + assert!( + parse_required_target_policy( + "required_accepted:duplicate", + duplicate_fingerprints.as_str(), + RadrootsTransportSatisfactionClass::Accepted, + 2, + ) + .is_err() + ); + assert!(required_count_u16(-1).is_err()); + assert!(required_count_u16(i64::from(u16::MAX) + 1).is_err()); + assert!(u32_from_i64("negative", -1).is_err()); + assert_eq!(bool_i64(false), 0); + assert_eq!(bool_i64(true), 1); + assert!(parse_optional_stored_trade_id("trade_id", None).is_ok()); + assert!(parse_optional_stored_trade_id("trade_id", Some("invalid".to_owned())).is_err()); + assert!( + parse_optional_stored_mutation_id("mutation_id", Some("invalid".to_owned())).is_err() + ); + assert!(matches!( + outcome_kind_for_status(RadrootsOutboxDeliveryTargetStatus::Pending), + RadrootsTransportOutcomeKind::TransportUnavailable + )); + assert!(parse_transport_outcome_kind("invalid", "outcome").is_err()); + for (stored, expected) in [ + ("accepted", RadrootsTransportOutcomeKind::Accepted), + ( + "duplicate_accepted", + RadrootsTransportOutcomeKind::DuplicateAccepted, + ), + ("delivered", RadrootsTransportOutcomeKind::Delivered), + ("forwarded", RadrootsTransportOutcomeKind::Forwarded), + ( + "stored_by_gateway", + RadrootsTransportOutcomeKind::StoredByGateway, + ), + ("seen", RadrootsTransportOutcomeKind::Seen), + ( + "deferred_until_implemented", + RadrootsTransportOutcomeKind::DeferredUntilImplemented, + ), + ("rejected", RadrootsTransportOutcomeKind::Rejected), + ( + "route_unavailable", + RadrootsTransportOutcomeKind::RouteUnavailable, + ), + ( + "payload_too_large", + RadrootsTransportOutcomeKind::PayloadTooLarge, + ), + ("policy_denied", RadrootsTransportOutcomeKind::PolicyDenied), + ("timeout", RadrootsTransportOutcomeKind::Timeout), + ( + "connection_failed", + RadrootsTransportOutcomeKind::ConnectionFailed, + ), + ( + "transport_unavailable", + RadrootsTransportOutcomeKind::TransportUnavailable, + ), + ] { + assert_eq!( + parse_transport_outcome_kind(stored, "outcome").expect("stored outcome"), + expected + ); + } + + let canonical = canonical_trade_proposal(); + let valid_draft = trade_mutation_draft(&canonical); + let valid_hash = sha256_hex(canonical.content.as_bytes()); + assert!(matches!( + validate_trade_mutation_input( + canonical.envelope.trade_id.as_str(), + canonical.mutation_id.as_str(), + valid_hash.as_str(), + &post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "not a trade mutation"), + ), + Err(RadrootsOutboxError::TradeMutationMetadataMismatch { field: "kind" }) + )); + for invalid_hash in ["short".to_owned(), "g".repeat(64)] { + assert!(matches!( + validate_trade_mutation_input( + canonical.envelope.trade_id.as_str(), + canonical.mutation_id.as_str(), + invalid_hash.as_str(), + &valid_draft, + ), + Err(RadrootsOutboxError::TradeMutationMetadataMismatch { + field: "canonical_payload_sha256" + }) + )); + } + let invalid_content = proposal_draft_with_content( + format!(" {}", canonical.content), + FIXTURE_ALICE_PUBLIC_KEY_HEX, + ); + assert!(matches!( + validate_trade_mutation_input( + canonical.envelope.trade_id.as_str(), + canonical.mutation_id.as_str(), + valid_hash.as_str(), + &invalid_content, + ), + Err(RadrootsOutboxError::TradeMutationMetadataMismatch { field: "content" }) + )); + + let author_mismatch = + proposal_draft_with_content(canonical.content.clone(), hex_64('b').as_str()); + assert!(matches!( + validate_trade_mutation_input( + canonical.envelope.trade_id.as_str(), + canonical.mutation_id.as_str(), + valid_hash.as_str(), + &author_mismatch, + ), + Err(RadrootsOutboxError::TradeMutationMetadataMismatch { + field: "author_pubkey" + }) + )); + assert!(matches!( + validate_trade_mutation_input( + hex_32('2').as_str(), + canonical.mutation_id.as_str(), + valid_hash.as_str(), + &valid_draft, + ), + Err(RadrootsOutboxError::TradeMutationMetadataMismatch { field: "trade_id" }) + )); + assert!(matches!( + validate_trade_mutation_input( + canonical.envelope.trade_id.as_str(), + hex_64('3').as_str(), + valid_hash.as_str(), + &valid_draft, + ), + Err(RadrootsOutboxError::TradeMutationMetadataMismatch { + field: "mutation_id" + }) + )); + assert!(matches!( + validate_trade_mutation_input( + canonical.envelope.trade_id.as_str(), + canonical.mutation_id.as_str(), + "0".repeat(64).as_str(), + &valid_draft, + ), + Err(RadrootsOutboxError::TradeMutationMetadataMismatch { + field: "canonical_payload_sha256" + }) + )); + + let envelope_without_mutation_id = proposal_envelope(); + let content_without_mutation_id = canonical_jcs_value( + &serde_json::to_value(&envelope_without_mutation_id).expect("proposal value"), + ) + .expect("canonical proposal without mutation id"); + let draft_without_mutation_id = proposal_draft_with_content( + content_without_mutation_id.clone(), + FIXTURE_ALICE_PUBLIC_KEY_HEX, + ); + assert!(matches!( + validate_trade_mutation_input( + envelope_without_mutation_id.trade_id.as_str(), + canonical.mutation_id.as_str(), + sha256_hex(content_without_mutation_id.as_bytes()).as_str(), + &draft_without_mutation_id, + ), + Err(RadrootsOutboxError::TradeMutationMetadataMismatch { + field: "mutation_id" + }) + )); + + let empty_no_wait = RadrootsOutboxDeliveryPlanInput::new( + "transport.none", + 1, + RadrootsTransportSatisfactionPolicy::no_wait(), + Vec::new(), + ); + assert!(prepare_delivery_plan("event-no-wait", &empty_no_wait).is_ok()); + } + fn malformed_reticulum_target(uri: &str) -> RadrootsTransportTarget { let endpoint_uri = RadrootsTransportTargetUri::parse(uri).expect("target uri"); let endpoint_fingerprint = RadrootsTransportTargetFingerprint::from_target( @@ -3681,6 +3912,23 @@ mod tests { ) } + fn trade_mutation_input( + canonical: &RadrootsTradeCanonicalMutationV1, + draft: RadrootsEventDraft, + targets: Vec<RadrootsTransportTarget>, + created_at_ms: i64, + ) -> RadrootsOutboxTradeMutationInput { + RadrootsOutboxTradeMutationInput::new( + "publish_trade_mutation", + canonical.envelope.trade_id.clone(), + canonical.mutation_id.clone(), + sha256_hex(canonical.content.as_bytes()), + draft, + delivery_plan(targets), + created_at_ms, + ) + } + fn fixture_keys() -> RadrootsNostrKeys { let secret_key = RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key"); @@ -3857,103 +4105,1055 @@ mod tests { } #[tokio::test] - async fn operation_and_delivery_plan_idempotency_are_split() { - let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(hex_64('a').as_str(), "hello"); - let first = outbox - .enqueue_operation(operation_input(draft.clone(), 1_000).with_idempotency_key("idem-a")) - .await - .expect("first"); - let same_plan = outbox - .enqueue_operation(operation_input(draft.clone(), 1_100).with_idempotency_key("idem-a")) - .await - .expect("same"); - let new_plan = outbox - .enqueue_operation( - RadrootsOutboxOperationInput::new( - "publish_post", - draft.clone(), - delivery_plan(vec![nostr_target("wss://relay-3.example.com")]), - 1_200, - ) - .with_idempotency_key("idem-a"), - ) + async fn constructors_preflight_and_transactional_enqueue_cover_public_storage_surfaces() { + let directory = tempfile::tempdir().expect("tempdir"); + let file_outbox = RadrootsOutbox::open_file(directory.path().join("outbox.sqlite")) .await - .expect("new plan"); - - assert_eq!(first.status, RadrootsOutboxEnqueueStatus::Inserted); - assert_eq!(same_plan.status, RadrootsOutboxEnqueueStatus::Existing); - assert_eq!(new_plan.status, RadrootsOutboxEnqueueStatus::Inserted); - assert_eq!(first.operation_id, same_plan.operation_id); - assert_eq!(first.operation_id, new_plan.operation_id); - assert_eq!(first.outbox_event_id, new_plan.outbox_event_id); + .expect("file outbox"); assert_eq!( - first.operation_idempotency_digest, - new_plan.operation_idempotency_digest - ); - assert_ne!( - first.delivery_plan_idempotency_digest, - new_plan.delivery_plan_idempotency_digest + file_outbox + .pragma_journal_mode() + .await + .expect("file journal mode"), + "wal" ); - assert_eq!(table_count(&outbox, "outbox_operations").await, 1); - assert_eq!(table_count(&outbox, "outbox_event").await, 1); - assert_eq!(table_count(&outbox, "outbox_delivery_plan").await, 2); - let conflict = outbox - .enqueue_operation( - operation_input(post_draft(hex_64('a').as_str(), "changed"), 1_300) - .with_idempotency_key("idem-a"), - ) + let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("memory options"); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) .await - .expect_err("conflict"); + .expect("memory pool"); + let outbox = RadrootsOutbox::open_pool(pool, false) + .await + .expect("pool outbox"); + + let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "preflight"); + let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft) + .expect("signed preflight event"); + let input = signed_operation_input(draft.clone(), signed_event.clone(), 1_000) + .with_idempotency_key("preflight-idem"); + outbox + .preflight_signed_operation_idempotency(&signed_operation_input( + draft.clone(), + signed_event.clone(), + 900, + )) + .await + .expect("preflight without idempotency key"); + outbox + .preflight_signed_operation_idempotency(&input) + .await + .expect("preflight before insert"); + outbox + .enqueue_signed_operation(input.clone()) + .await + .expect("signed insert"); + outbox + .preflight_signed_operation_idempotency(&input) + .await + .expect("matching existing preflight"); + + let changed_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "preflight changed"); + let changed_signed = radroots_nostr_sign_frozen_draft(&fixture_keys(), &changed_draft) + .expect("changed signed event"); + let conflict = signed_operation_input(changed_draft, changed_signed, 1_100) + .with_idempotency_key("preflight-idem"); assert!(matches!( - conflict, - RadrootsOutboxError::IdempotencyConflict { .. } + outbox + .preflight_signed_operation_idempotency(&conflict) + .await, + Err(RadrootsOutboxError::IdempotencyConflict { .. }) )); + + let transaction_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "transaction"); + let transaction_signed = + radroots_nostr_sign_frozen_draft(&fixture_keys(), &transaction_draft) + .expect("transaction signed event"); + let transaction_input = + signed_operation_input(transaction_draft.clone(), transaction_signed, 2_000) + .with_idempotency_key("transaction-idem"); + let mut transaction = outbox.pool().begin().await.expect("begin transaction"); + let inserted = outbox + .enqueue_signed_operation_in_transaction(&mut transaction, transaction_input.clone()) + .await + .expect("transaction insert"); + let existing = outbox + .enqueue_signed_operation_in_transaction(&mut transaction, transaction_input.clone()) + .await + .expect("transaction existing"); + assert_eq!(inserted.operation_id, existing.operation_id); + + let transaction_changed = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "transaction changed"); + let transaction_changed_signed = + radroots_nostr_sign_frozen_draft(&fixture_keys(), &transaction_changed) + .expect("transaction changed signed event"); + let transaction_conflict = + signed_operation_input(transaction_changed, transaction_changed_signed, 2_100) + .with_idempotency_key("transaction-idem"); + assert!(matches!( + outbox + .enqueue_signed_operation_in_transaction(&mut transaction, transaction_conflict,) + .await, + Err(RadrootsOutboxError::IdempotencyConflict { .. }) + )); + transaction.commit().await.expect("commit transaction"); } #[tokio::test] - async fn generic_enqueue_rejects_trade_mutation_drafts_before_persistence() { + async fn defensive_storage_decoding_and_remaining_idempotency_edges_are_explicit() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let canonical = canonical_trade_proposal(); - let (draft, signed_event) = signed_trade_mutation(&canonical); - - let unsigned_err = outbox - .enqueue_operation(RadrootsOutboxOperationInput::new( - "publish_trade_mutation", + let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "defensive storage"); + let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft) + .expect("signed defensive event"); + let signed_receipt = outbox + .enqueue_signed_operation(signed_operation_input( draft.clone(), - delivery_plan(vec![nostr_target(NOSTR_PRIMARY_WSS)]), + signed_event.clone(), 1_000, )) .await - .expect_err("generic unsigned trade rejection"); - assert!(matches!( - unsigned_err, - RadrootsOutboxError::TradeMutationRequiresSemanticOutbox - )); + .expect("signed insert without idempotency key"); - let signed_err = outbox - .enqueue_signed_operation(RadrootsOutboxSignedOperationInput::new( - "publish_trade_mutation", - draft, - signed_event, - delivery_plan(vec![nostr_target(NOSTR_PRIMARY_WSS)]), - true, - 1_007, - 1_000, - )) + let keyed_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "keyed public insert"); + let keyed_signed = radroots_nostr_sign_frozen_draft(&fixture_keys(), &keyed_draft) + .expect("keyed signed event"); + outbox + .enqueue_signed_operation( + signed_operation_input(keyed_draft, keyed_signed, 1_100) + .with_idempotency_key("public-signed-conflict"), + ) .await - .expect_err("generic signed trade rejection"); + .expect("keyed public signed insert"); + let changed_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "keyed public conflict"); + let changed_signed = radroots_nostr_sign_frozen_draft(&fixture_keys(), &changed_draft) + .expect("changed signed event"); assert!(matches!( - signed_err, - RadrootsOutboxError::TradeMutationRequiresSemanticOutbox + outbox + .enqueue_signed_operation( + signed_operation_input(changed_draft, changed_signed, 1_200) + .with_idempotency_key("public-signed-conflict"), + ) + .await, + Err(RadrootsOutboxError::IdempotencyConflict { .. }) )); - assert_eq!(table_count(&outbox, "outbox_operations").await, 0); - assert_eq!(table_count(&outbox, "outbox_event").await, 0); - assert_eq!(table_count(&outbox, "outbox_delivery_plan").await, 0); - } - #[tokio::test] + let canonical = canonical_trade_proposal(); + let invalid_trade_draft = post_draft( + FIXTURE_ALICE_PUBLIC_KEY_HEX, + "not a semantic trade mutation", + ); + let invalid_trade_signed = + radroots_nostr_sign_frozen_draft(&fixture_keys(), &invalid_trade_draft) + .expect("invalid trade draft still forms a valid signed event"); + assert!(matches!( + outbox + .preflight_signed_trade_mutation_idempotency(&signed_trade_mutation_input( + &canonical, + invalid_trade_draft.clone(), + invalid_trade_signed, + vec![nostr_target("wss://invalid-trade-preflight.example")], + 1_300, + )) + .await, + Err(RadrootsOutboxError::TradeMutationMetadataMismatch { field: "kind" }) + )); + assert!(matches!( + outbox + .enqueue_trade_mutation_operation(trade_mutation_input( + &canonical, + invalid_trade_draft, + vec![nostr_target("wss://invalid-trade-enqueue.example")], + 1_400, + )) + .await, + Err(RadrootsOutboxError::TradeMutationMetadataMismatch { field: "kind" }) + )); + + let (trade_draft, trade_signed_event) = signed_trade_mutation(&canonical); + outbox + .preflight_signed_trade_mutation_idempotency(&signed_trade_mutation_input( + &canonical, + trade_draft.clone(), + trade_signed_event, + vec![nostr_target("wss://trade-preflight-no-key.example")], + 1_500, + )) + .await + .expect("trade preflight without idempotency key"); + let signed_trade_without_key = RadrootsOutbox::open_memory() + .await + .expect("open isolated signed trade outbox"); + let (isolated_trade_draft, isolated_trade_event) = signed_trade_mutation(&canonical); + signed_trade_without_key + .enqueue_signed_trade_mutation_operation(signed_trade_mutation_input( + &canonical, + isolated_trade_draft, + isolated_trade_event, + vec![nostr_target("wss://signed-trade-no-key.example")], + 1_550, + )) + .await + .expect("signed trade insert without idempotency key"); + let trade_operation = outbox + .enqueue_trade_mutation_operation(trade_mutation_input( + &canonical, + trade_draft, + vec![nostr_target("wss://trade-storage.example")], + 1_600, + )) + .await + .expect("trade insert without idempotency key"); + + let transaction_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "transaction no key"); + let transaction_signed = + radroots_nostr_sign_frozen_draft(&fixture_keys(), &transaction_draft) + .expect("transaction signed event"); + let mut transaction = outbox.pool().begin().await.expect("begin transaction"); + outbox + .enqueue_signed_operation_in_transaction( + &mut transaction, + signed_operation_input(transaction_draft, transaction_signed, 1_700), + ) + .await + .expect("transaction insert without idempotency key"); + assert!(matches!( + claimed_event_identity_tx(&mut transaction, i64::MAX, "missing").await, + Err(RadrootsOutboxError::EventNotFound(_)) + )); + assert!(matches!( + claimed_event_identity_tx( + &mut transaction, + signed_receipt.outbox_event_id, + "wrong-token", + ) + .await, + Err(RadrootsOutboxError::ClaimTokenMismatch { .. }) + )); + assert!(matches!( + claimed_event_from_tx( + &mut transaction, + signed_receipt.outbox_event_id, + RadrootsOutboxEventState::Publishing, + None, + "unused-token", + ) + .await, + Err(RadrootsOutboxError::MissingActiveDeliveryPlan { .. }) + )); + transaction.commit().await.expect("commit transaction"); + + let event = outbox + .get_event(signed_receipt.outbox_event_id) + .await + .expect("event query") + .expect("event"); + let plans = outbox + .delivery_plans(signed_receipt.outbox_event_id) + .await + .expect("plans"); + let targets = outbox + .delivery_targets(signed_receipt.outbox_event_id) + .await + .expect("targets"); + assert!(reticulum_event_record(None, targets.clone()).is_none()); + assert!(reticulum_event_record(Some(event.clone()), Vec::new()).is_none()); + assert!(reticulum_event_record(Some(event.clone()), targets.clone()).is_some()); + assert_eq!( + outbox_satisfied_target_count( + &RadrootsTransportSatisfactionPolicy::no_wait(), + &targets, + ), + 0 + ); + assert_eq!( + outbox_satisfied_target_count( + &RadrootsTransportSatisfactionPolicy::any_accepted(), + &targets, + ), + 0 + ); + let mut accepted_target = targets[0].clone(); + accepted_target.status = RadrootsOutboxDeliveryTargetStatus::Accepted; + assert_eq!( + outbox_satisfied_target_count( + &RadrootsTransportSatisfactionPolicy::required_targets( + RadrootsTransportSatisfactionClass::Accepted, + vec![accepted_target.endpoint_fingerprint.clone()], + ) + .expect("required-target policy"), + &[accepted_target], + ), + 1 + ); + + sqlx::query("PRAGMA ignore_check_constraints = ON") + .execute(outbox.pool()) + .await + .expect("disable check constraints for defensive decoding"); + + sqlx::query("UPDATE outbox_operations SET trade_id = 'invalid' WHERE operation_id = ?") + .bind(trade_operation.operation_id) + .execute(outbox.pool()) + .await + .expect("corrupt trade id"); + assert!( + outbox + .get_operation(trade_operation.operation_id) + .await + .is_err() + ); + sqlx::query("UPDATE outbox_operations SET trade_id = ?, mutation_id = 'invalid' WHERE operation_id = ?") + .bind(canonical.envelope.trade_id.as_str()) + .bind(trade_operation.operation_id) + .execute(outbox.pool()) + .await + .expect("restore trade id and corrupt mutation id"); + assert!( + outbox + .get_operation(trade_operation.operation_id) + .await + .is_err() + ); + sqlx::query("UPDATE outbox_operations SET mutation_id = ? WHERE operation_id = ?") + .bind(canonical.mutation_id.as_str()) + .bind(trade_operation.operation_id) + .execute(outbox.pool()) + .await + .expect("restore mutation id"); + + sqlx::query("UPDATE outbox_event SET raw_event_json = NULL WHERE outbox_event_id = ?") + .bind(signed_receipt.outbox_event_id) + .execute(outbox.pool()) + .await + .expect("remove raw signed event JSON"); + assert!(matches!( + outbox.get_event(signed_receipt.outbox_event_id).await, + Err(RadrootsOutboxError::StoredSignedEventMissingRawJson(_)) + )); + sqlx::query("UPDATE outbox_event SET raw_event_json = ?, signed_event_json = NULL WHERE outbox_event_id = ?") + .bind(signed_event.raw_json()) + .bind(signed_receipt.outbox_event_id) + .execute(outbox.pool()) + .await + .expect("restore raw JSON and remove signed wire JSON"); + assert!(matches!( + outbox.get_event(signed_receipt.outbox_event_id).await, + Err(RadrootsOutboxError::StoredRawEventMissingSignedEvent(_)) + )); + sqlx::query( + "UPDATE outbox_event SET signed_event_json = ?, event_id = ? WHERE outbox_event_id = ?", + ) + .bind(signed_event_wire_json(&signed_event).expect("signed wire JSON")) + .bind(hex_64('0')) + .bind(signed_receipt.outbox_event_id) + .execute(outbox.pool()) + .await + .expect("restore wire JSON and corrupt event id"); + assert!(matches!( + outbox.get_event(signed_receipt.outbox_event_id).await, + Err(RadrootsOutboxError::SignedEventIdMismatch { .. }) + )); + sqlx::query("UPDATE outbox_event SET event_id = ? WHERE outbox_event_id = ?") + .bind(event.event_id.as_str()) + .bind(signed_receipt.outbox_event_id) + .execute(outbox.pool()) + .await + .expect("restore event id"); + + sqlx::query("UPDATE outbox_delivery_plan SET satisfaction_policy = 'invalid' WHERE delivery_plan_id = ?") + .bind(plans[0].delivery_plan_id) + .execute(outbox.pool()) + .await + .expect("corrupt satisfaction policy"); + assert!( + outbox + .delivery_plans(signed_receipt.outbox_event_id) + .await + .is_err() + ); + sqlx::query("UPDATE outbox_delivery_plan SET satisfaction_policy = ?, target_policy_version = -1 WHERE delivery_plan_id = ?") + .bind(satisfaction_policy_storage_value(&plans[0].satisfaction_policy)) + .bind(plans[0].delivery_plan_id) + .execute(outbox.pool()) + .await + .expect("restore policy and corrupt version"); + assert!( + outbox + .delivery_plans(signed_receipt.outbox_event_id) + .await + .is_err() + ); + sqlx::query( + "UPDATE outbox_delivery_plan SET target_policy_version = ? WHERE delivery_plan_id = ?", + ) + .bind(i64::from(plans[0].target_policy_version)) + .bind(plans[0].delivery_plan_id) + .execute(outbox.pool()) + .await + .expect("restore target policy version"); + + sqlx::query("UPDATE outbox_delivery_target SET endpoint_fingerprint = 'invalid' WHERE delivery_target_id = ?") + .bind(targets[0].delivery_target_id) + .execute(outbox.pool()) + .await + .expect("corrupt target fingerprint"); + assert!( + outbox + .delivery_targets(signed_receipt.outbox_event_id) + .await + .is_err() + ); + sqlx::query("UPDATE outbox_delivery_target SET endpoint_fingerprint = ? WHERE delivery_target_id = ?") + .bind(targets[0].endpoint_fingerprint.as_str()) + .bind(targets[0].delivery_target_id) + .execute(outbox.pool()) + .await + .expect("restore target fingerprint"); + + let attempt = sqlx::query( + "INSERT INTO outbox_delivery_attempt(delivery_plan_id, delivery_target_id, status, outcome_kind, attempted_at_ms) VALUES (?, ?, 'accepted', 'accepted', ?)", + ) + .bind(plans[0].delivery_plan_id) + .bind(targets[0].delivery_target_id) + .bind(2_000_i64) + .execute(outbox.pool()) + .await + .expect("insert delivery attempt"); + sqlx::query("UPDATE outbox_delivery_attempt SET outcome_kind = 'invalid' WHERE delivery_attempt_id = ?") + .bind(attempt.last_insert_rowid()) + .execute(outbox.pool()) + .await + .expect("corrupt attempt outcome"); + assert!( + outbox + .delivery_attempts(targets[0].delivery_target_id) + .await + .is_err() + ); + + sqlx::query("PRAGMA ignore_check_constraints = OFF") + .execute(outbox.pool()) + .await + .expect("restore check constraints"); + } + + #[tokio::test] + async fn signed_plan_lifecycle_and_reticulum_limit_cover_boundary_states() { + let outbox = RadrootsOutbox::open_memory().await.expect("open"); + assert!(matches!( + outbox.reticulum_events(None, usize::MAX).await, + Err(RadrootsOutboxError::IntegerRange { + field: "reticulum_events.limit", + .. + }) + )); + + let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "plan lifecycle"); + let signed_event = + radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("sign plan lifecycle"); + let first = outbox + .enqueue_signed_operation( + RadrootsOutboxSignedOperationInput::new( + "publish_post", + draft.clone(), + signed_event.clone(), + delivery_plan(vec![nostr_target("wss://plan-one.example")]), + true, + 1_007, + 1_000, + ) + .with_idempotency_key("plan-lifecycle"), + ) + .await + .expect("first plan"); + outbox + .enqueue_signed_operation( + RadrootsOutboxSignedOperationInput::new( + "publish_post", + draft, + signed_event, + delivery_plan(vec![nostr_target("wss://plan-two.example")]), + true, + 1_107, + 1_100, + ) + .with_idempotency_key("plan-lifecycle"), + ) + .await + .expect("second plan"); + let plans = outbox + .delivery_plans(first.outbox_event_id) + .await + .expect("plans"); + assert_eq!(plans.len(), 2); + + let mut transaction = outbox.pool().begin().await.expect("begin lifecycle"); + sqlx::query( + "UPDATE outbox_delivery_plan SET status = 'complete' WHERE delivery_plan_id = ?", + ) + .bind(plans[0].delivery_plan_id) + .execute(&mut *transaction) + .await + .expect("complete first plan"); + sqlx::query( + "UPDATE outbox_delivery_plan SET status = 'cancelled' WHERE delivery_plan_id = ?", + ) + .bind(plans[1].delivery_plan_id) + .execute(&mut *transaction) + .await + .expect("cancel second plan"); + assert_eq!( + signed_event_lifecycle_for_plans(&mut transaction, first.outbox_event_id) + .await + .expect("mixed lifecycle"), + ( + RadrootsOutboxEventState::Signed, + RadrootsOutboxOperationStatus::Queued, + ) + ); + sqlx::query( + "UPDATE outbox_delivery_plan SET status = 'cancelled' WHERE outbox_event_id = ?", + ) + .bind(first.outbox_event_id) + .execute(&mut *transaction) + .await + .expect("cancel all plans"); + assert_eq!( + signed_event_lifecycle_for_plans(&mut transaction, first.outbox_event_id) + .await + .expect("cancelled lifecycle"), + ( + RadrootsOutboxEventState::Cancelled, + RadrootsOutboxOperationStatus::Cancelled, + ) + ); + sqlx::query("DELETE FROM outbox_delivery_plan WHERE outbox_event_id = ?") + .bind(first.outbox_event_id) + .execute(&mut *transaction) + .await + .expect("delete plans"); + assert_eq!( + signed_event_lifecycle_for_plans(&mut transaction, first.outbox_event_id) + .await + .expect("empty lifecycle"), + ( + RadrootsOutboxEventState::Signed, + RadrootsOutboxOperationStatus::Queued, + ) + ); + transaction.rollback().await.expect("rollback lifecycle"); + } + + #[tokio::test] + async fn operation_and_delivery_plan_idempotency_are_split() { + let outbox = RadrootsOutbox::open_memory().await.expect("open"); + let draft = post_draft(hex_64('a').as_str(), "hello"); + let first = outbox + .enqueue_operation(operation_input(draft.clone(), 1_000).with_idempotency_key("idem-a")) + .await + .expect("first"); + let same_plan = outbox + .enqueue_operation(operation_input(draft.clone(), 1_100).with_idempotency_key("idem-a")) + .await + .expect("same"); + let new_plan = outbox + .enqueue_operation( + RadrootsOutboxOperationInput::new( + "publish_post", + draft.clone(), + delivery_plan(vec![nostr_target("wss://relay-3.example.com")]), + 1_200, + ) + .with_idempotency_key("idem-a"), + ) + .await + .expect("new plan"); + + assert_eq!(first.status, RadrootsOutboxEnqueueStatus::Inserted); + assert_eq!(same_plan.status, RadrootsOutboxEnqueueStatus::Existing); + assert_eq!(new_plan.status, RadrootsOutboxEnqueueStatus::Inserted); + assert_eq!(first.operation_id, same_plan.operation_id); + assert_eq!(first.operation_id, new_plan.operation_id); + assert_eq!(first.outbox_event_id, new_plan.outbox_event_id); + assert_eq!( + first.operation_idempotency_digest, + new_plan.operation_idempotency_digest + ); + assert_ne!( + first.delivery_plan_idempotency_digest, + new_plan.delivery_plan_idempotency_digest + ); + assert_eq!(table_count(&outbox, "outbox_operations").await, 1); + assert_eq!(table_count(&outbox, "outbox_event").await, 1); + assert_eq!(table_count(&outbox, "outbox_delivery_plan").await, 2); + + let conflict = outbox + .enqueue_operation( + operation_input(post_draft(hex_64('a').as_str(), "changed"), 1_300) + .with_idempotency_key("idem-a"), + ) + .await + .expect_err("conflict"); + assert!(matches!( + conflict, + RadrootsOutboxError::IdempotencyConflict { .. } + )); + } + + #[tokio::test] + async fn claim_retry_recovery_and_stale_update_guards_cover_worker_lifecycle() { + let outbox = RadrootsOutbox::open_memory().await.expect("open"); + assert!( + outbox + .claim_next_ready_event("worker", "empty", 100, 0) + .await + .expect("empty next claim") + .is_none() + ); + assert!( + outbox + .claim_ready_signed_event(99_999, "worker", "missing", 100, 0) + .await + .expect("missing direct claim") + .is_none() + ); + + let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "retry signing"); + let receipt = outbox + .enqueue_operation(operation_input(draft.clone(), 1_000)) + .await + .expect("enqueue unsigned"); + let signed = + radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("sign queued event"); + let claimed = outbox + .claim_next_ready_event("signer", "sign-claim", 2_000, 1_000) + .await + .expect("claim unsigned") + .expect("unsigned claim"); + assert_eq!(claimed.outbox_event_id, receipt.outbox_event_id); + assert!(matches!( + outbox + .complete_signing( + receipt.outbox_event_id, + "wrong-claim", + signed.clone(), + 1_010, + ) + .await, + Err(RadrootsOutboxError::ClaimTokenMismatch { .. }) + )); + + let other_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "different event"); + let other_signed = radroots_nostr_sign_frozen_draft(&fixture_keys(), &other_draft) + .expect("sign different event"); + assert!(matches!( + outbox + .complete_signing(receipt.outbox_event_id, "sign-claim", other_signed, 1_020,) + .await, + Err(RadrootsOutboxError::SignedEventIdMismatch { .. }) + )); + + sqlx::query( + "CREATE TRIGGER ignore_complete_signing BEFORE UPDATE OF signed_event_json ON outbox_event WHEN OLD.outbox_event_id = 1 AND NEW.signed_event_json IS NOT NULL BEGIN SELECT RAISE(IGNORE); END", + ) + .execute(outbox.pool()) + .await + .expect("complete signing trigger"); + assert!(matches!( + outbox + .complete_signing(receipt.outbox_event_id, "sign-claim", signed.clone(), 1_030,) + .await, + Err(RadrootsOutboxError::ClaimTokenMismatch { .. }) + )); + sqlx::query("DROP TRIGGER ignore_complete_signing") + .execute(outbox.pool()) + .await + .expect("drop complete signing trigger"); + + outbox + .mark_sign_retryable( + receipt.outbox_event_id, + "sign-claim", + "try signing again", + 1_040, + 1_035, + ) + .await + .expect("mark sign retryable"); + let reclaimed = outbox + .claim_next_ready_event("signer", "sign-claim-two", 2_100, 1_040) + .await + .expect("reclaim unsigned") + .expect("reclaimed unsigned"); + assert_eq!(reclaimed.state, RadrootsOutboxEventState::Signing); + outbox + .complete_signing(receipt.outbox_event_id, "sign-claim-two", signed, 1_050) + .await + .expect("complete signing"); + + let direct = outbox + .claim_ready_signed_event( + receipt.outbox_event_id, + "publisher", + "publish-claim", + 2_200, + 1_050, + ) + .await + .expect("claim signed directly") + .expect("direct signed claim"); + assert_eq!(direct.state, RadrootsOutboxEventState::Publishing); + assert!( + outbox + .claim_ready_signed_event( + receipt.outbox_event_id, + "publisher", + "publish-race", + 2_300, + 1_060, + ) + .await + .expect("stale direct claim") + .is_none() + ); + outbox + .mark_publish_retryable( + receipt.outbox_event_id, + "publish-claim", + "try publishing again", + 1_070, + 1_065, + ) + .await + .expect("mark publish retryable"); + + let expiring = outbox + .claim_next_ready_signed_event("publisher", "expiring", 1_080, 1_070) + .await + .expect("claim expiring") + .expect("expiring claim"); + assert_eq!(expiring.outbox_event_id, receipt.outbox_event_id); + assert_eq!( + outbox + .recover_expired_claims(1_080) + .await + .expect("recover expired claim"), + 1 + ); + assert_eq!( + outbox + .get_event(receipt.outbox_event_id) + .await + .expect("event after recovery") + .expect("event") + .state, + RadrootsOutboxEventState::PublishRetryable + ); + + let guarded = outbox + .claim_next_ready_signed_event("publisher", "guarded", 2_400, 1_080) + .await + .expect("claim for guarded retry") + .expect("guarded claim"); + assert_eq!(guarded.outbox_event_id, receipt.outbox_event_id); + sqlx::query( + "CREATE TRIGGER ignore_publish_retry BEFORE UPDATE OF state ON outbox_event WHEN NEW.state = 'publish_retryable' BEGIN SELECT RAISE(IGNORE); END", + ) + .execute(outbox.pool()) + .await + .expect("publish retry trigger"); + assert!(matches!( + outbox + .mark_publish_retryable( + receipt.outbox_event_id, + "guarded", + "ignored update", + 2_500, + 1_090, + ) + .await, + Err(RadrootsOutboxError::ClaimTokenMismatch { .. }) + )); + sqlx::query("DROP TRIGGER ignore_publish_retry") + .execute(outbox.pool()) + .await + .expect("drop publish retry trigger"); + } + + #[tokio::test] + async fn claim_compare_and_swap_guards_report_lost_worker_races() { + let unsigned_outbox = RadrootsOutbox::open_memory().await.expect("open unsigned"); + let unsigned_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "unsigned race"); + unsigned_outbox + .enqueue_operation(operation_input(unsigned_draft, 1_000)) + .await + .expect("enqueue unsigned race"); + sqlx::query( + "CREATE TRIGGER ignore_unsigned_claim BEFORE UPDATE OF claim_token ON outbox_event WHEN NEW.claim_token = 'race-next' BEGIN SELECT RAISE(IGNORE); END", + ) + .execute(unsigned_outbox.pool()) + .await + .expect("unsigned claim trigger"); + assert!( + unsigned_outbox + .claim_next_ready_event("worker", "race-next", 2_000, 1_000) + .await + .expect("lost unsigned claim race") + .is_none() + ); + + let signed_outbox = RadrootsOutbox::open_memory().await.expect("open signed"); + let signed_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "signed race"); + let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &signed_draft) + .expect("sign race event"); + signed_outbox + .enqueue_signed_operation(signed_operation_input(signed_draft, signed_event, 1_000)) + .await + .expect("enqueue signed race"); + sqlx::query( + "CREATE TRIGGER ignore_signed_claim BEFORE UPDATE OF claim_token ON outbox_event WHEN NEW.claim_token = 'race-signed' BEGIN SELECT RAISE(IGNORE); END", + ) + .execute(signed_outbox.pool()) + .await + .expect("signed claim trigger"); + assert!( + signed_outbox + .claim_next_ready_signed_event("worker", "race-signed", 2_000, 1_000) + .await + .expect("lost signed claim race") + .is_none() + ); + } + + #[tokio::test] + async fn claimed_publish_mutations_enforce_plan_identity_and_atomic_updates() { + let outbox = RadrootsOutbox::open_memory().await.expect("open"); + let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "publish guards"); + let signed_event = + radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("sign publish guards"); + let receipt = outbox + .enqueue_signed_operation(signed_operation_input(draft, signed_event, 1_000)) + .await + .expect("enqueue publish guards"); + let claimed = outbox + .claim_next_ready_signed_event("publisher", "publish-guards", 2_000, 1_000) + .await + .expect("claim publish guards") + .expect("claimed publish guards"); + let target_id = claimed.delivery_targets[0].delivery_target_id; + + sqlx::query( + "CREATE TRIGGER ignore_target_update BEFORE UPDATE OF status ON outbox_delivery_target WHEN NEW.status = 'accepted' BEGIN SELECT RAISE(IGNORE); END", + ) + .execute(outbox.pool()) + .await + .expect("target update trigger"); + assert!(matches!( + outbox + .mark_delivery_target_accepted( + receipt.outbox_event_id, + "publish-guards", + target_id, + 1_010, + ) + .await, + Err(RadrootsOutboxError::DeliveryTargetNotFound(_)) + )); + sqlx::query("DROP TRIGGER ignore_target_update") + .execute(outbox.pool()) + .await + .expect("drop target update trigger"); + + for (trigger_name, operation) in [ + ("ignore_complete_publish", "complete"), + ("ignore_terminal_publish", "terminal"), + ("ignore_cancel_publish", "cancel"), + ] { + let trigger = format!( + "CREATE TRIGGER {trigger_name} BEFORE UPDATE OF state ON outbox_event WHEN OLD.claim_token = 'publish-guards' AND NEW.claim_token IS NULL BEGIN SELECT RAISE(IGNORE); END" + ); + sqlx::query(sqlx::AssertSqlSafe(trigger)) + .execute(outbox.pool()) + .await + .expect("event update trigger"); + let error = match operation { + "complete" => outbox + .complete_publish_attempt( + receipt.outbox_event_id, + "publish-guards", + "retry", + "terminal", + 1_100, + 1_020, + ) + .await + .expect_err("ignored complete update"), + "terminal" => outbox + .mark_active_delivery_plan_failed_terminal( + receipt.outbox_event_id, + "publish-guards", + "terminal", + 1_020, + ) + .await + .expect_err("ignored terminal update"), + "cancel" => outbox + .cancel_claimed_event(receipt.outbox_event_id, "publish-guards", 1_020) + .await + .expect_err("ignored cancel update"), + _ => unreachable!(), + }; + assert!(matches!( + error, + RadrootsOutboxError::ClaimTokenMismatch { .. } + )); + let drop_trigger = format!("DROP TRIGGER {trigger_name}"); + sqlx::query(sqlx::AssertSqlSafe(drop_trigger)) + .execute(outbox.pool()) + .await + .expect("drop event update trigger"); + } + + sqlx::query("PRAGMA ignore_check_constraints = ON") + .execute(outbox.pool()) + .await + .expect("ignore target check constraint"); + sqlx::query( + "UPDATE outbox_delivery_target SET status = 'invalid' WHERE delivery_target_id = ?", + ) + .bind(target_id) + .execute(outbox.pool()) + .await + .expect("corrupt target status"); + assert!(matches!( + outbox + .mark_delivery_target_accepted( + receipt.outbox_event_id, + "publish-guards", + target_id, + 1_030, + ) + .await, + Err(RadrootsOutboxError::InvalidStoredEnum { .. }) + )); + sqlx::query( + "UPDATE outbox_delivery_target SET status = 'pending' WHERE delivery_target_id = ?", + ) + .bind(target_id) + .execute(outbox.pool()) + .await + .expect("restore target status"); + sqlx::query("PRAGMA ignore_check_constraints = OFF") + .execute(outbox.pool()) + .await + .expect("restore target check constraints"); + + sqlx::query( + "UPDATE outbox_event SET active_delivery_plan_id = NULL WHERE outbox_event_id = ?", + ) + .bind(receipt.outbox_event_id) + .execute(outbox.pool()) + .await + .expect("remove active plan"); + assert!(matches!( + outbox + .complete_publish_attempt( + receipt.outbox_event_id, + "publish-guards", + "retry", + "terminal", + 1_100, + 1_040, + ) + .await, + Err(RadrootsOutboxError::MissingActiveDeliveryPlan { .. }) + )); + assert!(matches!( + outbox + .mark_active_delivery_plan_failed_terminal( + receipt.outbox_event_id, + "publish-guards", + "terminal", + 1_040, + ) + .await, + Err(RadrootsOutboxError::MissingActiveDeliveryPlan { .. }) + )); + assert!(matches!( + outbox + .mark_delivery_target_accepted( + receipt.outbox_event_id, + "publish-guards", + target_id, + 1_040, + ) + .await, + Err(RadrootsOutboxError::MissingActiveDeliveryPlan { .. }) + )); + + assert!(matches!( + outbox + .mark_sign_retryable(99_999, "missing", "missing", 1_100, 1_050) + .await, + Err(RadrootsOutboxError::EventNotFound(_)) + )); + assert!(matches!( + outbox + .mark_sign_retryable( + receipt.outbox_event_id, + "wrong-token", + "wrong token", + 1_100, + 1_050, + ) + .await, + Err(RadrootsOutboxError::ClaimTokenMismatch { .. }) + )); + } + + #[tokio::test] + async fn generic_enqueue_rejects_trade_mutation_drafts_before_persistence() { + let outbox = RadrootsOutbox::open_memory().await.expect("open"); + let canonical = canonical_trade_proposal(); + let (draft, signed_event) = signed_trade_mutation(&canonical); + + let unsigned_err = outbox + .enqueue_operation(RadrootsOutboxOperationInput::new( + "publish_trade_mutation", + draft.clone(), + delivery_plan(vec![nostr_target(NOSTR_PRIMARY_WSS)]), + 1_000, + )) + .await + .expect_err("generic unsigned trade rejection"); + assert!(matches!( + unsigned_err, + RadrootsOutboxError::TradeMutationRequiresSemanticOutbox + )); + + let signed_err = outbox + .enqueue_signed_operation(RadrootsOutboxSignedOperationInput::new( + "publish_trade_mutation", + draft, + signed_event, + delivery_plan(vec![nostr_target(NOSTR_PRIMARY_WSS)]), + true, + 1_007, + 1_000, + )) + .await + .expect_err("generic signed trade rejection"); + assert!(matches!( + signed_err, + RadrootsOutboxError::TradeMutationRequiresSemanticOutbox + )); + assert_eq!(table_count(&outbox, "outbox_operations").await, 0); + assert_eq!(table_count(&outbox, "outbox_event").await, 0); + assert_eq!(table_count(&outbox, "outbox_delivery_plan").await, 0); + } + + #[tokio::test] async fn semantic_trade_mutation_enqueue_persists_metadata_and_deduplicates_by_mutation() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); let canonical = canonical_trade_proposal(); @@ -4033,6 +5233,169 @@ mod tests { } #[tokio::test] + async fn unsigned_trade_and_defensive_idempotency_paths_preserve_semantic_identity() { + let outbox = RadrootsOutbox::open_memory().await.expect("open"); + let canonical = canonical_trade_proposal(); + let (draft, signed_event) = signed_trade_mutation(&canonical); + let unsigned = |uri: &str, created_at_ms| { + trade_mutation_input( + &canonical, + draft.clone(), + vec![nostr_target(uri)], + created_at_ms, + ) + .with_idempotency_key("unsigned-trade-idem") + }; + let signed = |uri: &str, created_at_ms| { + signed_trade_mutation_input( + &canonical, + draft.clone(), + signed_event.clone(), + vec![nostr_target(uri)], + created_at_ms, + ) + .with_idempotency_key("unsigned-trade-idem") + }; + + let first = outbox + .enqueue_trade_mutation_operation(unsigned("wss://unsigned-one.example", 1_000)) + .await + .expect("unsigned insert"); + let existing = outbox + .enqueue_trade_mutation_operation(unsigned("wss://unsigned-one.example", 1_100)) + .await + .expect("unsigned existing plan"); + let inserted_plan = outbox + .enqueue_trade_mutation_operation(unsigned("wss://unsigned-two.example", 1_200)) + .await + .expect("unsigned new plan"); + assert_eq!(existing.status, RadrootsOutboxEnqueueStatus::Existing); + assert_eq!(inserted_plan.status, RadrootsOutboxEnqueueStatus::Inserted); + + outbox + .preflight_signed_trade_mutation_idempotency(&signed( + "wss://unsigned-three.example", + 1_300, + )) + .await + .expect("matching trade preflight"); + + sqlx::query("UPDATE outbox_operations SET mutation_id = ? WHERE operation_id = ?") + .bind("stored-other-mutation") + .bind(first.operation_id) + .execute(outbox.pool()) + .await + .expect("move stored mutation identity"); + let idempotent_existing = outbox + .enqueue_trade_mutation_operation(unsigned("wss://unsigned-one.example", 1_400)) + .await + .expect("unsigned idempotent existing plan"); + let idempotent_inserted = outbox + .enqueue_trade_mutation_operation(unsigned("wss://unsigned-four.example", 1_500)) + .await + .expect("unsigned idempotent new plan"); + assert_eq!( + idempotent_existing.status, + RadrootsOutboxEnqueueStatus::Existing + ); + assert_eq!( + idempotent_inserted.status, + RadrootsOutboxEnqueueStatus::Inserted + ); + + sqlx::query( + "UPDATE outbox_operations SET operation_idempotency_digest = 'corrupt' WHERE operation_id = ?", + ) + .bind(first.operation_id) + .execute(outbox.pool()) + .await + .expect("corrupt stored digest"); + assert!(matches!( + outbox + .enqueue_trade_mutation_operation(unsigned( + "wss://unsigned-conflict.example", + 1_600, + )) + .await, + Err(RadrootsOutboxError::IdempotencyConflict { .. }) + )); + assert!(matches!( + outbox + .preflight_signed_trade_mutation_idempotency(&signed( + "wss://signed-preflight-conflict.example", + 1_700, + )) + .await, + Err(RadrootsOutboxError::IdempotencyConflict { .. }) + )); + assert!(matches!( + outbox + .enqueue_signed_trade_mutation_operation(signed( + "wss://signed-idempotent-conflict.example", + 1_800, + )) + .await, + Err(RadrootsOutboxError::IdempotencyConflict { .. }) + )); + + sqlx::query( + "UPDATE outbox_operations SET operation_idempotency_digest = ? WHERE operation_id = ?", + ) + .bind(first.operation_idempotency_digest.as_str()) + .bind(first.operation_id) + .execute(outbox.pool()) + .await + .expect("restore stored digest"); + let signed_idempotent = outbox + .enqueue_signed_trade_mutation_operation(signed( + "wss://signed-idempotent.example", + 1_900, + )) + .await + .expect("signed idempotent branch"); + assert_eq!( + signed_idempotent.status, + RadrootsOutboxEnqueueStatus::Inserted + ); + + sqlx::query( + "UPDATE outbox_operations SET mutation_id = ?, operation_idempotency_digest = 'corrupt' WHERE operation_id = ?", + ) + .bind(canonical.mutation_id.as_str()) + .bind(first.operation_id) + .execute(outbox.pool()) + .await + .expect("restore mutation and corrupt digest"); + assert!(matches!( + outbox + .enqueue_trade_mutation_operation(unsigned( + "wss://unsigned-mutation-conflict.example", + 2_000, + )) + .await, + Err(RadrootsOutboxError::IdempotencyConflict { .. }) + )); + assert!(matches!( + outbox + .preflight_signed_trade_mutation_idempotency(&signed( + "wss://signed-mutation-preflight.example", + 2_100, + )) + .await, + Err(RadrootsOutboxError::IdempotencyConflict { .. }) + )); + assert!(matches!( + outbox + .enqueue_signed_trade_mutation_operation(signed( + "wss://signed-mutation-conflict.example", + 2_200, + )) + .await, + Err(RadrootsOutboxError::IdempotencyConflict { .. }) + )); + } + + #[tokio::test] async fn semantic_trade_mutation_enqueue_rejects_payload_hash_mismatch() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); let canonical = canonical_trade_proposal(); diff --git a/crates/trade/src/listing/draft.rs b/crates/trade/src/listing/draft.rs @@ -56,7 +56,7 @@ impl RadrootsCanonicalListingEdit { validate_listing_bins(&listing)?; let public_listing_addr = - listing_addr(KIND_LISTING, &seller_pubkey, listing.d_tag.as_str())?; + listing_addr(KIND_LISTING, &seller_pubkey, listing.d_tag.as_str()); Ok(Self { listing, @@ -128,9 +128,9 @@ fn listing_addr( kind: u32, seller_pubkey: &RadrootsPublicKey, d_tag: &str, -) -> Result<RadrootsListingAddress, RadrootsListingEditError> { +) -> RadrootsListingAddress { RadrootsListingAddress::parse(format!("{kind}:{}:{d_tag}", seller_pubkey.as_str())) - .map_err(RadrootsListingEditError::InvalidListingAddress) + .expect("typed listing identity must form a listing address") } pub fn canonicalize_listing_edit( diff --git a/crates/trade/src/listing/mod.rs b/crates/trade/src/listing/mod.rs @@ -11,7 +11,7 @@ use radroots_event::{ RadrootsAddressableCoordinateParts, RadrootsDTag, RadrootsIdParseError, RadrootsListingAddress, RadrootsPublicKey, }, - kinds::{KIND_LISTING, is_listing_kind}, + kinds::is_listing_kind, listing::RadrootsListing, }; use thiserror::Error; @@ -80,7 +80,7 @@ pub fn parse_listing_address( let address = RadrootsListingAddress::parse(value) .map_err(RadrootsListingAddressError::InvalidAddress)?; let parts = RadrootsAddressableCoordinateParts::parse(address.as_str()) - .map_err(RadrootsListingAddressError::InvalidAddress)?; + .expect("typed listing address must contain valid coordinate parts"); ensure_listing_kind(parts.kind)?; Ok(RadrootsListingAddressParts { address, @@ -101,7 +101,6 @@ pub fn parse_public_listing_address( RadrootsPublicListingAddressError::InvalidListingKind { actual } } })?; - ensure_public_listing_kind(parts.kind)?; Ok(RadrootsPublicListingAddress { address: parts.address, kind: parts.kind, @@ -118,14 +117,6 @@ fn ensure_listing_kind(kind: u32) -> Result<(), RadrootsListingAddressError> { Ok(()) } -#[cfg_attr(coverage_nightly, coverage(off))] -fn ensure_public_listing_kind(kind: u32) -> Result<(), RadrootsPublicListingAddressError> { - if kind != KIND_LISTING { - return Err(RadrootsPublicListingAddressError::InvalidKind { actual: kind }); - } - Ok(()) -} - pub fn parse_listing_event( event: &RadrootsEventEnvelope, ) -> Result<RadrootsListing, ListingParseError> { diff --git a/crates/trade/src/listing/validation.rs b/crates/trade/src/listing/validation.rs @@ -15,7 +15,6 @@ use radroots_event::{ RadrootsListingPublicLocation, }, location::{has_textual_locality, is_public_geohash5}, - order::RadrootsListingParseError, trade_validation::RadrootsTradeValidationListingError as TradeListingValidationError, }; @@ -61,9 +60,7 @@ pub fn validate_listing_event( } let listing_addr_raw = format!("{}:{}:{}", event.kind_u32(), seller_pubkey, listing_id); let listing_addr = RadrootsListingAddress::parse(&listing_addr_raw) - .map_err(|_| TradeListingValidationError::ParseError { - error: RadrootsListingParseError::InvalidTag("listing_addr".to_string()), - })? + .expect("validated listing identity must form a listing address") .into_string(); let title = listing.product.title.trim().to_string(); diff --git a/crates/trade/src/validation_receipt.rs b/crates/trade/src/validation_receipt.rs @@ -449,7 +449,8 @@ pub fn validator_set_canonical_content( validator_set: &RadrootsValidatorSetV1, ) -> Result<String, RadrootsValidationReceiptError> { validator_set.validate()?; - serde_json::to_string(validator_set).map_err(|_| RadrootsValidationReceiptError::InvalidJson) + Ok(serde_json::to_string(validator_set) + .expect("validated validator sets contain only serializable contract values")) } pub fn validator_set_content_from_str( @@ -507,13 +508,6 @@ pub fn verify_validator_set_event( )); } let address = validator_set_address(event.author(), &validator_set.set_id)?; - let parts = RadrootsAddressableCoordinateParts::parse(address.as_str()) - .map_err(|_| RadrootsValidationReceiptError::InvalidField("validator_set.address"))?; - if parts.kind != KIND_VALIDATOR_SET || parts.pubkey != *event.author() { - return Err(RadrootsValidationReceiptError::InvalidField( - "validator_set.address", - )); - } Ok(RadrootsVerifiedValidatorSetV1 { set: validator_set, event_id: event.id_str().to_owned(), @@ -1049,7 +1043,7 @@ fn validate_validator_set_address( field: &'static str, ) -> Result<(), RadrootsValidationReceiptError> { let parts = RadrootsAddressableCoordinateParts::parse(value.as_str()) - .map_err(|_| RadrootsValidationReceiptError::InvalidField(field))?; + .expect("typed addressable coordinates must contain valid coordinate parts"); if parts.kind != KIND_VALIDATOR_SET { return Err(RadrootsValidationReceiptError::InvalidField(field)); } @@ -1147,12 +1141,12 @@ mod tests { validation_receipt_event_build, validation_receipt_from_event, validation_receipt_public_values_hash_hex, validation_receipt_tags, validation_receipt_tags_from_tags, validator_set_address, validator_set_canonical_content, - validator_set_event_build, validator_set_from_event, verify_validation_receipt_event, - verify_validator_set_event, + validator_set_content_from_str, validator_set_event_build, validator_set_from_event, + verify_validation_receipt_event, verify_validator_set_event, }; use radroots_event::{ RadrootsEventEnvelope, RadrootsEventEnvelopeParts, - ids::RadrootsPublicKey, + ids::{RadrootsAddressableCoordinate, RadrootsPublicKey}, kinds::{KIND_TRADE_VALIDATION_RECEIPT, KIND_VALIDATOR_SET}, tags::TAG_D, }; @@ -1270,6 +1264,23 @@ mod tests { validation_receipt_event_with_parts(parts.kind, tags, parts.content) } + fn validator_set_event_with_parts( + kind: u32, + tags: Vec<Vec<String>>, + content: String, + ) -> RadrootsEventEnvelope { + RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { + id: event_id('7'), + author: validator_set_author().as_str().to_string(), + created_at: 1_700_000_001, + kind, + tags, + content, + sig: "f".repeat(128), + }) + .expect("validator set event") + } + #[test] fn validation_receipt_labels_cover_all_variants() { assert_eq!( @@ -1436,6 +1447,55 @@ mod tests { } #[test] + fn validator_set_parsing_and_event_verification_reject_each_boundary() { + let validator_set = sample_validator_set(); + let canonical = validator_set_canonical_content(&validator_set).expect("canonical content"); + let pretty = serde_json::to_string_pretty(&validator_set).expect("pretty content"); + assert_eq!( + validator_set_content_from_str(&pretty), + Err(RadrootsValidationReceiptError::NonCanonicalJson) + ); + assert_eq!( + validator_set_content_from_str("{"), + Err(RadrootsValidationReceiptError::InvalidJson) + ); + + let parts = validator_set_event_build(&validator_set).expect("validator set parts"); + let wrong_kind = validator_set_event_with_parts( + KIND_TRADE_VALIDATION_RECEIPT, + parts.tags.clone(), + canonical.clone(), + ); + assert_eq!( + verify_validator_set_event(&wrong_kind, None), + Err(RadrootsValidationReceiptError::InvalidKind { + expected: KIND_VALIDATOR_SET, + got: KIND_TRADE_VALIDATION_RECEIPT, + }) + ); + + let mut mismatched_tags = parts.tags; + mismatched_tags[0][1] = "018f3d99-7d35-7c0c-8a0f-7f3b645abcdf".to_string(); + let mismatched = + validator_set_event_with_parts(KIND_VALIDATOR_SET, mismatched_tags, canonical); + assert_eq!( + verify_validator_set_event(&mismatched, None), + Err(RadrootsValidationReceiptError::TagMismatch( + "validator_set.set_id" + )) + ); + + let mut invalid = sample_validator_set(); + invalid.threshold = 2; + assert_eq!( + validator_set_event_build(&invalid), + Err(RadrootsValidationReceiptError::InvalidField( + "validator_set.threshold" + )) + ); + } + + #[test] fn validation_receipt_validate_rejects_core_field_errors() { let mut receipt = sample_validation_receipt(); receipt.version = 2; @@ -1554,6 +1614,29 @@ mod tests { ); let mut receipt = sample_validation_receipt(); + receipt.statement.validator_set_event_id = "bad".to_string(); + assert_eq!( + receipt.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "statement.validator_set_event_id" + )) + ); + + let mut receipt = sample_validation_receipt(); + receipt.statement.validator_set_addr = RadrootsAddressableCoordinate::parse(format!( + "1:{}:{}", + validator_set_author(), + validator_set_id() + )) + .expect("typed non-validator address"); + assert_eq!( + receipt.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "statement.validator_set_addr" + )) + ); + + let mut receipt = sample_validation_receipt(); receipt.error_bitmap = "0x00000000000000000000000000000001".to_string(); assert_eq!( receipt.validate(), @@ -1744,6 +1827,15 @@ mod tests { )) ); + let mut duplicate_validator_set_addr = tags.clone(); + duplicate_validator_set_addr.push(tags[4].clone()); + assert_eq!( + validation_receipt_tags_from_tags(&duplicate_validator_set_addr), + Err(RadrootsValidationReceiptError::InvalidTag( + TAG_VALIDATION_RECEIPT_VALIDATOR_SET_MARKER + )) + ); + let mut invalid_validator_set_event = tags.clone(); invalid_validator_set_event[5][1] = "bad".to_string(); assert_eq!( @@ -2611,4 +2703,125 @@ mod tests { Err(RadrootsValidationReceiptError::EmptyField("order_id")) ); } + + #[test] + fn validator_set_validation_covers_every_boundary() { + let valid_id = validator_set_id(); + for variant in ["8", "9", "a", "b"] { + let mut value = valid_id.clone(); + value.replace_range(19..20, variant); + assert_eq!(super::validate_uuidv7(&value, "uuid"), Ok(())); + } + let mut invalid_ids = vec![String::new(), "bad".to_string()]; + for (range, replacement) in [ + (8..9, "0"), + (13..14, "0"), + (18..19, "0"), + (23..24, "0"), + (14..15, "6"), + (19..20, "7"), + (0..1, "g"), + ] { + let mut value = valid_id.clone(); + value.replace_range(range, replacement); + invalid_ids.push(value); + } + for invalid in invalid_ids { + assert!(matches!( + super::validate_uuidv7(&invalid, "uuid"), + Err(RadrootsValidationReceiptError::EmptyField("uuid")) + | Err(RadrootsValidationReceiptError::InvalidField("uuid")) + )); + } + + let mut validator_set = sample_validator_set(); + validator_set.threshold = 2; + assert_eq!( + validator_set.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "validator_set.threshold" + )) + ); + let mut validator_set = sample_validator_set(); + validator_set.valid_until = validator_set.valid_from; + assert_eq!( + validator_set.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "validator_set.valid_until" + )) + ); + let mut validator_set = sample_validator_set(); + validator_set.protocol_contract_hash = "bad".to_string(); + assert_eq!( + validator_set.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "validator_set.protocol_contract_hash" + )) + ); + let mut validator_set = sample_validator_set(); + validator_set.operator_name = " ".to_string(); + assert_eq!( + validator_set.validate(), + Err(RadrootsValidationReceiptError::EmptyField( + "validator_set.operator_name" + )) + ); + let mut validator_set = sample_validator_set(); + validator_set.operator_name = "x".repeat(121); + assert_eq!( + validator_set.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "validator_set.operator_name" + )) + ); + let mut validator_set = sample_validator_set(); + validator_set.operator_contact = Some(" ".to_string()); + assert_eq!( + validator_set.validate(), + Err(RadrootsValidationReceiptError::EmptyField( + "validator_set.operator_contact" + )) + ); + let mut validator_set = sample_validator_set(); + validator_set.operator_contact = Some("x".repeat(241)); + assert_eq!( + validator_set.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "validator_set.operator_contact" + )) + ); + let mut validator_set = sample_validator_set(); + validator_set.operator_contact = None; + validator_set.validate().expect("contact is optional"); + + let address = super::validator_set_address_from_str(validator_set_addr().as_str()) + .expect("validator set address"); + assert_eq!(address, validator_set_addr()); + assert_eq!( + super::validator_set_address_from_str("bad"), + Err(RadrootsValidationReceiptError::InvalidField( + "validator_set.address" + )) + ); + let wrong_kind = format!("1:{}:{}", validator_set_author(), validator_set_id()); + assert_eq!( + super::validator_set_address_from_str(wrong_kind), + Err(RadrootsValidationReceiptError::InvalidField( + "validator_set.address" + )) + ); + + let empty = RadrootsTradeValidationTrustPolicy::production(); + assert!(!empty.has_validator_set()); + assert!(!empty.trusts_validator_pubkey(&validator_set_pubkey())); + assert_eq!(empty.validator_count(), 0); + + let partial = RadrootsTradeValidationTrustPolicy { + validator_set: Some(sample_validator_set()), + validator_set_addr: None, + validator_set_event_id: Some(event_id('8')), + require_cryptographic_proof: false, + }; + assert!(!partial.has_validator_set()); + } } diff --git a/crates/trade/src/workflow.rs b/crates/trade/src/workflow.rs @@ -377,6 +377,14 @@ struct CandidateRecord { candidate: RadrootsTradeCandidateTermsV1, } +struct DecisionApplication<'a> { + mutation_id: &'a RadrootsTradeMutationId, + mutation: &'a RadrootsTradeMutationEnvelopeV1, + proposal_mutation_id: &'a RadrootsTradeMutationId, + candidate_id: &'a RadrootsTradeCandidateId, + decision: &'a RadrootsTradeDecisionV1, +} + #[derive(Clone, Debug, PartialEq, Eq)] struct CancellationRecord { mutation_id: RadrootsTradeMutationId, @@ -437,14 +445,11 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra let mut claims = BTreeMap::<RadrootsTradeMutationId, RadrootsTradeAgreementClaimV1>::new(); let mut decisions_by_proposal = BTreeMap::<RadrootsTradeMutationId, Vec<RadrootsTradeMutationId>>::new(); - let mut decision_ids = Vec::<RadrootsTradeMutationId>::new(); let mut cancellations = Vec::<CancellationRecord>::new(); let mut referenced_parents = BTreeSet::<RadrootsTradeMutationId>::new(); for (mutation_id, mutation) in &mutations { - if mutation.parent_mutation_ids.is_empty() - && matches!(mutation.body, RadrootsTradeMutationBodyV1::Proposal { .. }) - { + if matches!(mutation.body, RadrootsTradeMutationBodyV1::Proposal { .. }) { root_proposals.push(mutation_id.clone()); } for parent in &mutation.parent_mutation_ids { @@ -462,31 +467,17 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra match &mutation.body { RadrootsTradeMutationBodyV1::Proposal { candidate } | RadrootsTradeMutationBodyV1::RevisionProposal { candidate } => { - if let Some(candidate_id) = candidate.candidate_id.clone() { - candidates_by_proposal.insert( - mutation_id.clone(), - CandidateRecord { - proposal_mutation_id: mutation_id.clone(), - author_pubkey: mutation.author_pubkey.clone(), - candidate: candidate.clone(), - }, - ); - let _ = candidate_id; - } - } - RadrootsTradeMutationBodyV1::Decision { - proposal_mutation_id, - candidate_id, - decision, - } - | RadrootsTradeMutationBodyV1::RevisionDecision { - proposal_mutation_id, - candidate_id, - decision, - } => { - let _ = (proposal_mutation_id, candidate_id, decision); - decision_ids.push(mutation_id.clone()); + candidates_by_proposal.insert( + mutation_id.clone(), + CandidateRecord { + proposal_mutation_id: mutation_id.clone(), + author_pubkey: mutation.author_pubkey.clone(), + candidate: candidate.clone(), + }, + ); } + RadrootsTradeMutationBodyV1::Decision { .. } + | RadrootsTradeMutationBodyV1::RevisionDecision { .. } => {} RadrootsTradeMutationBodyV1::Cancellation { target_candidate_id, target_claim_mutation_id, @@ -500,10 +491,7 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra } } - for mutation_id in decision_ids { - let Some(mutation) = mutations.get(&mutation_id) else { - continue; - }; + for (mutation_id, mutation) in &mutations { match &mutation.body { RadrootsTradeMutationBodyV1::Decision { proposal_mutation_id, @@ -520,11 +508,13 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra .or_default() .push(mutation_id.clone()); apply_decision( - &mutation_id, - mutation, - proposal_mutation_id, - candidate_id, - decision, + DecisionApplication { + mutation_id, + mutation, + proposal_mutation_id, + candidate_id, + decision, + }, &candidates_by_proposal, &mut claims, &mut projection, @@ -544,15 +534,14 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra .push(RadrootsTradeReducerIssueV1::MultipleRootProposals); } else { projection.root_mutation_id = root_proposals.first().cloned(); - if let Some(root) = projection + let root = projection .root_mutation_id .as_ref() .and_then(|root_id| mutations.get(root_id)) - { - projection.buyer_pubkey = Some(root.buyer_pubkey.clone()); - projection.seller_pubkey = Some(root.seller_pubkey.clone()); - projection.farm_id = Some(root.farm_id.clone()); - } + .expect("root proposal selected from the validated mutation map"); + projection.buyer_pubkey = Some(root.buyer_pubkey.clone()); + projection.seller_pubkey = Some(root.seller_pubkey.clone()); + projection.farm_id = Some(root.farm_id.clone()); } for (proposal_mutation_id, decision_ids) in &decisions_by_proposal { @@ -620,13 +609,14 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra | RadrootsTradePrivateTermsStateV1::CommitmentMismatch ) { for claim_id in &projection.active_agreement_claim_ids { - if let Some(claim) = claims.get(claim_id) { - projection - .issues - .push(RadrootsTradeReducerIssueV1::PrivateTermsUnavailable { - candidate_id: claim.candidate_id.clone(), - }); - } + let claim = claims + .get(claim_id) + .expect("active agreement identifiers originate from indexed claims"); + projection + .issues + .push(RadrootsTradeReducerIssueV1::PrivateTermsUnavailable { + candidate_id: claim.candidate_id.clone(), + }); } } projection.attestations = input.attestations; @@ -637,15 +627,18 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra } fn apply_decision( - mutation_id: &RadrootsTradeMutationId, - mutation: &RadrootsTradeMutationEnvelopeV1, - proposal_mutation_id: &RadrootsTradeMutationId, - candidate_id: &RadrootsTradeCandidateId, - decision: &RadrootsTradeDecisionV1, + application: DecisionApplication<'_>, candidates_by_proposal: &BTreeMap<RadrootsTradeMutationId, CandidateRecord>, claims: &mut BTreeMap<RadrootsTradeMutationId, RadrootsTradeAgreementClaimV1>, projection: &mut RadrootsTradeProjectionV1, ) { + let DecisionApplication { + mutation_id, + mutation, + proposal_mutation_id, + candidate_id, + decision, + } = application; let Some(candidate_record) = candidates_by_proposal.get(proposal_mutation_id) else { projection .missing_proposal_ids @@ -671,10 +664,7 @@ fn apply_decision( ); return; } - if mutation.author_pubkey != mutation.counterparty_pubkey - && mutation.author_pubkey - != candidate_record_author_counterparty(candidate_record, mutation) - { + if mutation.author_pubkey != candidate_record_author_counterparty(candidate_record, mutation) { projection .issues .push(RadrootsTradeReducerIssueV1::DecisionAuthorMismatch { @@ -891,9 +881,9 @@ fn non_dominated_claim_ids( } fn compatible_claims(claims: &[&RadrootsTradeAgreementClaimV1]) -> bool { - let Some(first) = claims.first() else { - return false; - }; + let first = claims + .first() + .expect("non-empty claims produce at least one non-dominated claim"); claims.iter().all(|claim| { claim.candidate_id == first.candidate_id && claim.reservation_commitment == first.reservation_commitment @@ -953,9 +943,9 @@ fn reduce_private_terms_state( .collect::<BTreeMap<_, _>>(); let mut required_states = Vec::new(); for claim in &projection.agreement_claims { - let Some(candidate_record) = candidates_by_proposal.get(&claim.proposal_mutation_id) else { - continue; - }; + let candidate_record = candidates_by_proposal + .get(&claim.proposal_mutation_id) + .expect("agreement claims originate from indexed candidates"); let requires_private_terms = candidate_record.candidate.private_terms.is_some() || candidate_record .candidate @@ -972,20 +962,11 @@ fn reduce_private_terms_state( } if required_states.is_empty() { RadrootsTradePrivateTermsStateV1::NotRequired - } else if required_states - .iter() - .any(|state| *state == RadrootsTradePrivateTermsStateV1::CommitmentMismatch) - { + } else if required_states.contains(&RadrootsTradePrivateTermsStateV1::CommitmentMismatch) { RadrootsTradePrivateTermsStateV1::CommitmentMismatch - } else if required_states - .iter() - .any(|state| *state == RadrootsTradePrivateTermsStateV1::Undecryptable) - { + } else if required_states.contains(&RadrootsTradePrivateTermsStateV1::Undecryptable) { RadrootsTradePrivateTermsStateV1::Undecryptable - } else if required_states - .iter() - .any(|state| *state == RadrootsTradePrivateTermsStateV1::Missing) - { + } else if required_states.contains(&RadrootsTradePrivateTermsStateV1::Missing) { RadrootsTradePrivateTermsStateV1::Missing } else { RadrootsTradePrivateTermsStateV1::AvailableVerified @@ -1074,24 +1055,10 @@ fn set_conflict( fn projection_digest(projection: &RadrootsTradeProjectionV1) -> String { let mut digest_input = projection.clone(); digest_input.projection_digest.clear(); - let value = match serde_json::to_value(&digest_input) { - Ok(value) => value, - Err(error) => { - let mut hasher = Sha256::new(); - hasher.update(RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN); - hasher.update(error.to_string().as_bytes()); - return hex::encode(hasher.finalize()); - } - }; - let canonical = match radroots_event::trade::canonical_jcs_value(&value) { - Ok(canonical) => canonical, - Err(error) => { - let mut hasher = Sha256::new(); - hasher.update(RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN); - hasher.update(error.to_string().as_bytes()); - return hex::encode(hasher.finalize()); - } - }; + let value = serde_json::to_value(&digest_input) + .expect("trade projection contains only serializable contract values"); + let canonical = radroots_event::trade::canonical_jcs_value(&value) + .expect("serialized trade projection must be canonicalizable"); let mut hasher = Sha256::new(); hasher.update(RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN); hasher.update(canonical.as_bytes()); @@ -1298,7 +1265,8 @@ mod tests { ) -> RadrootsTradeMutationEnvelopeV1 { let proposal_id = proposal.mutation_id.clone().unwrap(); let candidate = match &proposal.body { - RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate.clone(), + RadrootsTradeMutationBodyV1::Proposal { candidate } + | RadrootsTradeMutationBodyV1::RevisionProposal { candidate } => candidate.clone(), _ => unreachable!(), }; canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 { @@ -1432,6 +1400,47 @@ mod tests { reduce_trade_records(input) } + fn recanonicalize( + mut mutation: RadrootsTradeMutationEnvelopeV1, + ) -> RadrootsTradeMutationEnvelopeV1 { + mutation.mutation_id = None; + canonical_trade_mutation_content(mutation) + .expect("recanonicalized mutation") + .envelope + } + + fn candidate_cancellation( + root: &RadrootsTradeMutationEnvelopeV1, + ) -> RadrootsTradeMutationEnvelopeV1 { + let candidate_id = match &root.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } => { + candidate.candidate_id.clone().expect("candidate id") + } + _ => unreachable!(), + }; + canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 { + mutation_id: None, + contract_id: radroots_event::trade::RADROOTS_TRADE_CANCELLATION_CONTRACT_ID.to_string(), + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + trade_id: trade_id(), + root_mutation_id: Some(root_id(root)), + buyer_pubkey: pubkey('a'), + seller_pubkey: pubkey('b'), + farm_id: dtag("farm-1"), + parent_mutation_ids: vec![root_id(root)], + author_pubkey: pubkey('a'), + counterparty_pubkey: pubkey('b'), + authored_at_unix_s: 301, + body: RadrootsTradeMutationBodyV1::Cancellation { + target_candidate_id: Some(candidate_id), + target_claim_mutation_id: None, + reason: "cancel before agreement".to_string(), + }, + }) + .expect("candidate cancellation") + .envelope + } + #[test] fn reducer_digest_is_independent_of_input_order_and_duplicates() { let proposal = proposal(); @@ -1617,4 +1626,559 @@ mod tests { RadrootsTradeAgreementStateV1::None ); } + + #[test] + fn reducer_classifies_malformed_unsupported_and_foreign_records() { + let empty = reduce(Vec::new()); + assert_eq!( + empty.negotiation_state, + RadrootsTradeNegotiationStateV1::None + ); + assert_eq!(empty.evidence_state, RadrootsTradeEvidenceStateV1::Missing); + assert!( + empty + .issues + .contains(&RadrootsTradeReducerIssueV1::MissingRootProposal) + ); + + let mut missing_id = proposal(); + missing_id.mutation_id = None; + let mut unsupported = proposal(); + unsupported.schema_version += 1; + let unsupported_id = root_id(&unsupported); + let mut invalid = proposal(); + invalid.contract_id = "invalid.contract".to_string(); + let mut second_root = proposal(); + second_root.authored_at_unix_s += 1; + let second_root = recanonicalize(second_root); + let projection = reduce(vec![ + missing_id.clone(), + missing_id, + unsupported, + invalid, + proposal(), + second_root, + ]); + assert!( + projection + .issues + .contains(&RadrootsTradeReducerIssueV1::MissingMutationId) + ); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::UnsupportedSchema { mutation_id, .. } + if mutation_id == &unsupported_id + ))); + assert!( + projection + .issues + .iter() + .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::InvalidMutation { .. })) + ); + assert!( + projection + .issues + .contains(&RadrootsTradeReducerIssueV1::MultipleRootProposals) + ); + + let foreign_trade = RadrootsTradeId::parse(hex_32('2')).expect("foreign trade"); + let mut input = RadrootsTradeReductionInputV1::new(foreign_trade); + input.mutations = vec![record(proposal())]; + let foreign = reduce_trade_records(input); + assert!(foreign.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::TradeIdentityMismatch { .. } + ))); + } + + #[test] + fn reducer_rejects_invalid_decision_relationships() { + let root = proposal(); + + let missing_proposal = reduce(vec![accepted_decision(&root, '1')]); + assert!( + missing_proposal + .issues + .iter() + .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::MissingProposal { .. })) + ); + + let mut wrong_candidate = accepted_decision(&root, '1'); + if let RadrootsTradeMutationBodyV1::Decision { candidate_id, .. } = + &mut wrong_candidate.body + { + *candidate_id = RadrootsTradeCandidateId::parse(hex_64('f')).expect("candidate id"); + } + let wrong_candidate = recanonicalize(wrong_candidate); + let projection = reduce(vec![root.clone(), wrong_candidate]); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::CandidateIdMismatch { .. } + ))); + + let mut wrong_author = accepted_decision(&root, '2'); + wrong_author.author_pubkey = wrong_author.buyer_pubkey.clone(); + wrong_author.counterparty_pubkey = wrong_author.seller_pubkey.clone(); + let wrong_author = recanonicalize(wrong_author); + let projection = reduce(vec![root.clone(), wrong_author]); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::DecisionAuthorMismatch { .. } + ))); + + let mut no_reservation = accepted_decision(&root, '3'); + if let RadrootsTradeMutationBodyV1::Decision { decision, .. } = &mut no_reservation.body { + *decision = RadrootsTradeDecisionV1::Accepted { + reservation_assertion: None, + }; + } + let no_reservation = recanonicalize(no_reservation); + let projection = reduce(vec![root, no_reservation]); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::MissingSellerReservation { .. } + ))); + } + + #[test] + fn reducer_rejects_every_reservation_mismatch() { + let root = proposal(); + let mut mismatched = accepted_decision(&root, '4'); + if let RadrootsTradeMutationBodyV1::Decision { + decision: + RadrootsTradeDecisionV1::Accepted { + reservation_assertion: Some(reservation), + }, + .. + } = &mut mismatched.body + { + reservation.candidate_id = + RadrootsTradeCandidateId::parse(hex_64('f')).expect("candidate id"); + reservation.inventory_authority_id = pubkey('a'); + reservation.commitments[0].unit_code = "kg".to_string(); + } + let mismatched = recanonicalize(mismatched); + let projection = reduce(vec![root.clone(), mismatched]); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::ReservationCandidateMismatch { .. } + ))); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::ReservationAuthorityMismatch { .. } + ))); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. } + ))); + assert_eq!( + projection.conflict_state, + RadrootsTradeConflictStateV1::InventoryAuthorityConflict + ); + + let mut wrong_count = accepted_decision(&root, '5'); + if let RadrootsTradeMutationBodyV1::Decision { + decision: + RadrootsTradeDecisionV1::Accepted { + reservation_assertion: Some(reservation), + }, + .. + } = &mut wrong_count.body + { + let mut extra = reservation.commitments[0].clone(); + extra.line_id = dtag("line-2"); + reservation.commitments.push(extra); + } + let wrong_count = recanonicalize(wrong_count); + let projection = reduce(vec![root, wrong_count]); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. } + ))); + } + + #[test] + fn reducer_covers_decision_conflict_and_ordered_cancellation() { + let root = proposal(); + let accepted = accepted_decision(&root, '1'); + let declined = declined_decision(&root); + let conflicted = reduce(vec![root.clone(), accepted.clone(), declined]); + assert_eq!( + conflicted.conflict_state, + RadrootsTradeConflictStateV1::DecisionConflict + ); + assert!( + conflicted + .issues + .iter() + .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::DecisionConflict { .. })) + ); + + let cancel = cancellation(&root, root_id(&accepted), root_id(&accepted)); + let cancelled = reduce(vec![root, accepted.clone(), cancel]); + assert_eq!( + cancelled.agreement_state, + RadrootsTradeAgreementStateV1::Cancelled + ); + assert_eq!(cancelled.cancelled_claim_ids, vec![root_id(&accepted)]); + } + + #[test] + fn reducer_covers_pre_agreement_cancellation_and_requested_evidence() { + let root = proposal(); + let cancel = candidate_cancellation(&root); + let cancelled = reduce(vec![root.clone(), cancel]); + assert_eq!( + cancelled.agreement_state, + RadrootsTradeAgreementStateV1::Cancelled + ); + + let decision = accepted_decision(&root, '1'); + let mut input = RadrootsTradeReductionInputV1::new(trade_id()); + input.mutations = vec![record(root), record(decision)]; + input.evidence_state = RadrootsTradeEvidenceStateV1::QueryPartial; + assert_eq!( + reduce_trade_records(input).evidence_state, + RadrootsTradeEvidenceStateV1::QueryPartial + ); + } + + #[test] + fn reducer_covers_private_terms_and_attestation_precedence() { + let mut root = proposal(); + if let RadrootsTradeMutationBodyV1::Proposal { candidate } = &mut root.body { + candidate.private_terms = Some(RadrootsTradePrivateTermsRefV1 { + artifact_id: "artifact-1".to_string(), + schema_id: "radroots.private.fulfillment.v1".to_string(), + ciphertext_commitment: hex_64('f'), + required_acknowledgement: true, + }); + } + let root = recanonicalize(root); + let candidate_id = match &root.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } => { + candidate.candidate_id.clone().expect("candidate id") + } + _ => unreachable!(), + }; + let decision = accepted_decision(&root, '1'); + for (state, expected) in [ + ( + RadrootsTradePrivateTermsStateV1::AvailableVerified, + RadrootsTradePrivateTermsStateV1::AvailableVerified, + ), + ( + RadrootsTradePrivateTermsStateV1::Undecryptable, + RadrootsTradePrivateTermsStateV1::Undecryptable, + ), + ( + RadrootsTradePrivateTermsStateV1::CommitmentMismatch, + RadrootsTradePrivateTermsStateV1::CommitmentMismatch, + ), + ] { + let mut input = RadrootsTradeReductionInputV1::new(trade_id()); + input.mutations = vec![record(root.clone()), record(decision.clone())]; + input.private_terms = vec![RadrootsTradePrivateTermsEvidenceV1 { + candidate_id: candidate_id.clone(), + state, + }]; + assert_eq!(reduce_trade_records(input).private_terms_state, expected); + } + + for (results, expected) in [ + ( + vec![RadrootsTradeAttestationResultV1::Valid], + RadrootsTradeAttestationStateV1::PresentValid, + ), + ( + vec![ + RadrootsTradeAttestationResultV1::Valid, + RadrootsTradeAttestationResultV1::Invalid, + ], + RadrootsTradeAttestationStateV1::Conflicting, + ), + ] { + let mut input = RadrootsTradeReductionInputV1::new(trade_id()); + input.mutations = vec![record(root.clone()), record(decision.clone())]; + input.attestations = results + .into_iter() + .enumerate() + .map(|(index, result)| RadrootsTradeAttestationRecordV1 { + event_id: event_id(if index == 0 { '8' } else { '9' }), + claim_mutation_id: root_id(&decision), + result, + }) + .collect(); + assert_eq!(reduce_trade_records(input).attestation_state, expected); + } + } + + #[test] + fn reducer_private_helpers_cover_empty_graph_and_conflict_precedence() { + let missing = RadrootsTradeMutationId::parse(hex_64('e')).expect("mutation id"); + assert!(ancestors_of(&missing, &BTreeMap::new(), &mut BTreeMap::new()).is_empty()); + + let mut conflict = RadrootsTradeConflictStateV1::DecisionConflict; + set_conflict( + &mut conflict, + RadrootsTradeConflictStateV1::ConcurrentCandidates, + ); + assert_eq!(conflict, RadrootsTradeConflictStateV1::DecisionConflict); + set_conflict( + &mut conflict, + RadrootsTradeConflictStateV1::InvalidCausalChain, + ); + assert_eq!(conflict, RadrootsTradeConflictStateV1::InvalidCausalChain); + } + + #[test] + fn reducer_rejects_self_identified_decision_author_bypass() { + let root = proposal(); + let mut decision = accepted_decision(&root, '7'); + decision.author_pubkey = decision.buyer_pubkey.clone(); + decision.counterparty_pubkey = decision.buyer_pubkey.clone(); + let decision = recanonicalize(decision); + + let projection = reduce(vec![root, decision]); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::DecisionAuthorMismatch { .. } + ))); + } + + #[test] + fn reducer_covers_all_decline_and_counterparty_shapes() { + let root = proposal(); + let first = declined_decision(&root); + let mut second = first.clone(); + second.authored_at_unix_s += 1; + if let RadrootsTradeMutationBodyV1::Decision { + decision: RadrootsTradeDecisionV1::Declined { reason }, + .. + } = &mut second.body + { + *reason = "still unavailable".to_string(); + } + let second = recanonicalize(second); + let projection = reduce(vec![root.clone(), first, second]); + assert_eq!( + projection.negotiation_state, + RadrootsTradeNegotiationStateV1::ClosedDeclined + ); + + let revision = revision_proposal(&root, vec![root_id(&root)]); + let mut revision_decline = declined_decision(&revision); + let RadrootsTradeMutationBodyV1::Decision { + proposal_mutation_id, + candidate_id, + decision, + } = revision_decline.body + else { + unreachable!(); + }; + revision_decline.contract_id = RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID.to_string(); + revision_decline.root_mutation_id = Some(root_id(&root)); + revision_decline.body = RadrootsTradeMutationBodyV1::RevisionDecision { + proposal_mutation_id, + candidate_id: candidate_id.clone(), + decision, + }; + let revision_decline = recanonicalize(revision_decline); + assert_eq!( + declined_candidate_ids(&BTreeMap::from([( + root_id(&revision_decline), + revision_decline, + )])), + vec![candidate_id] + ); + + let candidate = match &root.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate.clone(), + _ => unreachable!(), + }; + let mut candidate_record = CandidateRecord { + proposal_mutation_id: root_id(&root), + author_pubkey: root.buyer_pubkey.clone(), + candidate, + }; + let decision = accepted_decision(&root, '8'); + assert_eq!( + candidate_record_author_counterparty(&candidate_record, &decision), + decision.seller_pubkey + ); + candidate_record.author_pubkey = decision.seller_pubkey.clone(); + assert_eq!( + candidate_record_author_counterparty(&candidate_record, &decision), + decision.buyer_pubkey + ); + } + + #[test] + fn reservation_line_validation_checks_each_field() { + let root = proposal(); + let candidate = match &root.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate.clone(), + _ => unreachable!(), + }; + let candidate_id = candidate.candidate_id.clone().expect("candidate id"); + let decision_mutation_id = root_id(&accepted_decision(&root, '9')); + + for field in 0..5 { + let mut reservation = reservation(&candidate, '9'); + match field { + 0 => reservation.commitments[0].line_id = dtag("line-other"), + 1 => reservation.commitments[0].bin_id = bin_id("bin-other"), + 2 => reservation.commitments[0].quantity_mantissa = "3".to_string(), + 3 => reservation.commitments[0].quantity_scale = 1, + 4 => reservation.commitments[0].unit_code = "kg".to_string(), + _ => unreachable!(), + } + let mut projection = RadrootsTradeProjectionV1::empty(trade_id()); + assert!(!validate_reservation( + &decision_mutation_id, + &candidate_id, + &candidate, + &reservation, + &mut projection, + )); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. } + ))); + } + } + + #[test] + fn agreement_and_cancellation_helpers_cover_nonterminal_shapes() { + let root = proposal(); + let first_decision = accepted_decision(&root, '1'); + let second_decision = accepted_decision(&root, '2'); + let first_id = root_id(&first_decision); + let second_id = root_id(&second_decision); + let candidate = match &root.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate.clone(), + _ => unreachable!(), + }; + let candidate_id = candidate.candidate_id.clone().expect("candidate id"); + let claim = |claim_mutation_id: RadrootsTradeMutationId| RadrootsTradeAgreementClaimV1 { + claim_mutation_id, + proposal_mutation_id: root_id(&root), + candidate_id: candidate_id.clone(), + candidate_author_pubkey: root.buyer_pubkey.clone(), + accepted_by_pubkey: root.seller_pubkey.clone(), + reservation_commitment: hex_64('a'), + }; + let claims = BTreeMap::from([ + (first_id.clone(), claim(first_id.clone())), + (second_id.clone(), claim(second_id.clone())), + ]); + let mutations = BTreeMap::from([ + (first_id.clone(), first_decision), + (second_id.clone(), second_decision), + ]); + let missing_claim = RadrootsTradeMutationId::parse(hex_64('e')).expect("missing claim"); + let cancellations = vec![ + CancellationRecord { + mutation_id: RadrootsTradeMutationId::parse(hex_64('3')).expect("cancellation"), + parent_mutation_ids: Vec::new(), + target_candidate_id: None, + target_claim_mutation_id: None, + }, + CancellationRecord { + mutation_id: RadrootsTradeMutationId::parse(hex_64('4')).expect("cancellation"), + parent_mutation_ids: Vec::new(), + target_candidate_id: None, + target_claim_mutation_id: Some(missing_claim), + }, + CancellationRecord { + mutation_id: RadrootsTradeMutationId::parse(hex_64('5')).expect("cancellation"), + parent_mutation_ids: vec![first_id.clone()], + target_candidate_id: None, + target_claim_mutation_id: Some(first_id.clone()), + }, + ]; + let mut projection = RadrootsTradeProjectionV1::empty(trade_id()); + apply_agreement_state( + &mut projection, + &claims, + &mutations, + &BTreeMap::new(), + &cancellations, + ); + assert_eq!( + projection.agreement_state, + RadrootsTradeAgreementStateV1::Agreed + ); + assert_eq!(projection.cancelled_claim_ids, vec![first_id]); + + let mut incompatible_claims = claims.clone(); + incompatible_claims + .get_mut(&second_id) + .expect("second claim") + .candidate_id = RadrootsTradeCandidateId::parse(hex_64('f')).expect("candidate"); + let mut incompatible = RadrootsTradeProjectionV1::empty(trade_id()); + apply_agreement_state( + &mut incompatible, + &incompatible_claims, + &mutations, + &BTreeMap::new(), + &[], + ); + assert_eq!( + incompatible.conflict_state, + RadrootsTradeConflictStateV1::DecisionConflict + ); + + let candidates = BTreeMap::from([( + root_id(&root), + CandidateRecord { + proposal_mutation_id: root_id(&root), + author_pubkey: root.author_pubkey.clone(), + candidate: candidate.clone(), + }, + )]); + let unknown_candidate = RadrootsTradeCandidateId::parse(hex_64('f')).expect("candidate"); + assert!(!cancellation_without_claim( + &[CancellationRecord { + mutation_id: RadrootsTradeMutationId::parse(hex_64('6')).expect("cancellation"), + parent_mutation_ids: Vec::new(), + target_candidate_id: Some(unknown_candidate), + target_claim_mutation_id: None, + }], + &candidates, + )); + let mut disabled_candidate = candidate; + disabled_candidate.cancellation.buyer_pre_agreement = false; + let disabled_candidates = BTreeMap::from([( + root_id(&root), + CandidateRecord { + proposal_mutation_id: root_id(&root), + author_pubkey: root.author_pubkey.clone(), + candidate: disabled_candidate, + }, + )]); + assert!(!cancellation_without_claim( + &[CancellationRecord { + mutation_id: RadrootsTradeMutationId::parse(hex_64('7')).expect("cancellation"), + parent_mutation_ids: Vec::new(), + target_candidate_id: Some(candidate_id), + target_claim_mutation_id: None, + }], + &disabled_candidates, + )); + } + + #[test] + fn evidence_state_covers_missing_proposal_independently() { + let root = proposal(); + let mut projection = RadrootsTradeProjectionV1::empty(trade_id()); + projection.root_mutation_id = Some(root_id(&root)); + projection + .missing_proposal_ids + .push(RadrootsTradeMutationId::parse(hex_64('e')).expect("proposal")); + assert_eq!( + reduce_evidence_state(&projection, RadrootsTradeEvidenceStateV1::Complete), + RadrootsTradeEvidenceStateV1::Missing + ); + } } diff --git a/crates/trade_sp1_guest/src/lib.rs b/crates/trade_sp1_guest/src/lib.rs @@ -768,17 +768,13 @@ fn validate_addressable_coordinate( field: &'static str, ) -> Result<(), RadrootsSp1TradeGuestError> { validate_required_str(value, field)?; - let mut parts = value.split(':'); - let Some(kind) = parts.next() else { + let Some((kind, remainder)) = value.split_once(':') else { return Err(RadrootsSp1TradeGuestError::InvalidEventEvidence(field)); }; - let Some(pubkey) = parts.next() else { + let Some((pubkey, d_tag)) = remainder.split_once(':') else { return Err(RadrootsSp1TradeGuestError::InvalidEventEvidence(field)); }; - let Some(d_tag) = parts.next() else { - return Err(RadrootsSp1TradeGuestError::InvalidEventEvidence(field)); - }; - if parts.next().is_some() + if d_tag.contains(':') || kind.parse::<u32>().is_err() || pubkey.len() != 64 || !is_lower_hex(pubkey) @@ -1641,6 +1637,20 @@ mod tests { } let mut public_values = execution.public_values.clone(); + public_values.validator_set_addr = None; + assert_eq!( + canonical_public_values_bytes(&public_values).expect_err("validator set address"), + RadrootsSp1TradeGuestError::EmptyField("validator_set_addr") + ); + + let mut public_values = execution.public_values.clone(); + public_values.validator_set_event_id = None; + assert_eq!( + canonical_public_values_bytes(&public_values).expect_err("validator set event id"), + RadrootsSp1TradeGuestError::EmptyField("validator_set_event_id") + ); + + let mut public_values = execution.public_values.clone(); public_values.error_bitmap = "0x1".to_string(); assert_eq!( canonical_public_values_bytes(&public_values).expect_err("error bitmap"), @@ -1698,6 +1708,22 @@ mod tests { .expect_err("upper hex64"), RadrootsSp1TradeGuestError::InvalidEventEvidence("upper_hex64") ); + let pubkey = "a".repeat(64); + for coordinate in [ + "not-a-coordinate".to_string(), + "1:missing-d-tag".to_string(), + format!("not-a-kind:{pubkey}:d"), + "1:short:d".to_string(), + format!("1:{}:d", "A".repeat(64)), + format!("1:{pubkey}: "), + format!("1:{pubkey}:d:extra"), + ] { + assert_eq!( + super::validate_addressable_coordinate(&coordinate, "coordinate") + .expect_err("invalid coordinate"), + RadrootsSp1TradeGuestError::InvalidEventEvidence("coordinate") + ); + } assert_eq!( RadrootsSp1TradeEventWorkflowPosition::Listing.as_str(), "listing" diff --git a/crates/transport/src/delivery.rs b/crates/transport/src/delivery.rs @@ -187,10 +187,7 @@ impl RadrootsTransportSatisfactionPolicy { &self, total_targets: usize, ) -> Result<usize, RadrootsTransportError> { - if matches!(self, Self::NoWait) { - return Ok(0); - } - if total_targets == 0 { + if total_targets == 0 && !matches!(self, Self::NoWait) { return Err(RadrootsTransportError::InvalidSatisfactionPolicy); } match self { diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs @@ -1,5 +1,6 @@ #![no_std] #![forbid(unsafe_code)] +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] extern crate alloc; @@ -46,3 +47,14 @@ pub use transport::{ RadrootsTransport, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, RadrootsTransportFuture, }; + +#[cfg(test)] +extern crate self as radroots_transport; + +#[cfg(test)] +extern crate std; + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +#[path = "../tests/transport.rs"] +mod tests; diff --git a/crates/transport/src/payload.rs b/crates/transport/src/payload.rs @@ -28,8 +28,12 @@ impl RadrootsTransportPayload { 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())?; + Self::signed_event_json(event_id.as_ref(), raw_json.as_ref()) + } + + fn signed_event_json(event_id: &str, raw_json: &str) -> Result<Self, RadrootsTransportError> { + let event_id = validate_hex_id(event_id)?; + let raw_json = validate_raw_json(raw_json)?; let digest = sha256_hex(raw_json.as_bytes()); Ok(Self::SignedEventJson { event_id, @@ -43,8 +47,16 @@ impl RadrootsTransportPayload { raw_json: impl AsRef<str>, digest: impl AsRef<str>, ) -> Result<Self, RadrootsTransportError> { - let payload = Self::unchecked_signed_event_json(event_id, raw_json)?; - validate_supplied_digest(payload.digest(), digest.as_ref())?; + Self::signed_event_json_with_digest(event_id.as_ref(), raw_json.as_ref(), digest.as_ref()) + } + + fn signed_event_json_with_digest( + event_id: &str, + raw_json: &str, + digest: &str, + ) -> Result<Self, RadrootsTransportError> { + let payload = Self::signed_event_json(event_id, raw_json)?; + validate_supplied_digest(payload.digest(), digest)?; Ok(payload) } @@ -52,8 +64,15 @@ impl RadrootsTransportPayload { 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())?; + Self::validated_mesh_frame_cbor(message_id.as_ref(), bytes.as_ref()) + } + + fn validated_mesh_frame_cbor( + message_id: &str, + bytes: &[u8], + ) -> Result<Self, RadrootsTransportError> { + let message_id = validate_token_id(message_id)?; + let bytes = validate_bytes(bytes)?; let digest = sha256_hex(bytes.as_slice()); Ok(Self::MeshFrameCbor { message_id, @@ -67,8 +86,20 @@ impl RadrootsTransportPayload { 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())?; + Self::validated_mesh_frame_cbor_with_digest( + message_id.as_ref(), + bytes.as_ref(), + digest.as_ref(), + ) + } + + fn validated_mesh_frame_cbor_with_digest( + message_id: &str, + bytes: &[u8], + digest: &str, + ) -> Result<Self, RadrootsTransportError> { + let payload = Self::validated_mesh_frame_cbor(message_id, bytes)?; + validate_supplied_digest(payload.digest(), digest)?; Ok(payload) } @@ -76,8 +107,12 @@ impl RadrootsTransportPayload { 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())?; + Self::validated_opaque_bytes(label.as_ref(), bytes.as_ref()) + } + + fn validated_opaque_bytes(label: &str, bytes: &[u8]) -> Result<Self, RadrootsTransportError> { + let label = validate_label(label)?; + let bytes = validate_bytes(bytes)?; let digest = sha256_hex(bytes.as_slice()); Ok(Self::OpaqueBytes { label, @@ -91,8 +126,16 @@ impl RadrootsTransportPayload { 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())?; + Self::validated_opaque_bytes_with_digest(label.as_ref(), bytes.as_ref(), digest.as_ref()) + } + + fn validated_opaque_bytes_with_digest( + label: &str, + bytes: &[u8], + digest: &str, + ) -> Result<Self, RadrootsTransportError> { + let payload = Self::validated_opaque_bytes(label, bytes)?; + validate_supplied_digest(payload.digest(), digest)?; Ok(payload) } diff --git a/crates/transport/src/target.rs b/crates/transport/src/target.rs @@ -212,10 +212,7 @@ impl RadrootsTransportTarget { RadrootsTransportKind::Nostr => RadrootsTransportTargetUri::parse_nostr_relay(raw_uri)?, _ => RadrootsTransportTargetUri::parse(raw_uri)?, }; - if kind == RadrootsTransportKind::Reticulum - && (raw_uri != RADROOTS_RETICULUM_ENDPOINT_URI - || uri.as_str() != RADROOTS_RETICULUM_ENDPOINT_URI) - { + if kind == RadrootsTransportKind::Reticulum && raw_uri != RADROOTS_RETICULUM_ENDPOINT_URI { return Err(RadrootsTransportError::InvalidTargetUri); } let scope = scope.or_else(|| default_scope_for_kind(&kind)); @@ -381,7 +378,7 @@ fn canonicalize_nostr_relay_authority( .next() .map(parse_nostr_relay_port_with_prefix) .transpose()?; - if host.is_empty() || !is_valid_nostr_relay_host(host) { + if !is_valid_nostr_relay_host(host) { return Err(RadrootsTransportError::InvalidTargetUri); } (host.to_ascii_lowercase(), port) diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -1,6 +1,7 @@ use radroots_transport::{ RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, RadrootsTransport, - RadrootsTransportCapabilities, RadrootsTransportDeliveryReceipt, + RadrootsTransportCapabilities, RadrootsTransportCapabilityAvailability, + RadrootsTransportCapabilityMaturity, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState, RadrootsTransportKind, @@ -9,11 +10,16 @@ use radroots_transport::{ RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, RadrootsTransportTargetUri, - ReticulumCapabilityReportV1, ReticulumDuplicateFragmentBehaviorV1, + ReticulumCapabilityReportV1, ReticulumDestinationV1, ReticulumDuplicateFragmentBehaviorV1, ReticulumFragmentIntegrityV1, ReticulumFragmentationModeV1, ReticulumGatewaySemanticsV1, ReticulumPrivacySemanticsV1, }; use serde_json::Value; +use std::borrow::ToOwned; +use std::boxed::Box; +use std::string::{String, ToString}; +use std::vec; +use std::vec::Vec; fn opaque_payload() -> RadrootsTransportPayload { RadrootsTransportPayload::opaque_bytes("transport-test-payload", b"transport payload") @@ -1239,3 +1245,375 @@ fn neutral_transport_trait_covers_status_delivery_and_fetch() { RadrootsTransportOutcomeKind::Seen ); } + +#[test] +fn delivery_contract_covers_every_policy_and_receipt_path() { + let one = RadrootsTransportTarget::nostr_relay("wss://one.example").expect("one"); + let two = RadrootsTransportTarget::nostr_relay("wss://two.example").expect("two"); + let request = RadrootsTransportDeliveryRequest::new( + "delivery", + opaque_payload(), + RadrootsTransportTargetSet::new(vec![one.clone(), two.clone()]).expect("target set"), + RadrootsTransportSatisfactionPolicy::all_accepted(), + ) + .with_now_ms(42); + assert_eq!(request.now_ms, 42); + + for (policy, class) in [ + ( + RadrootsTransportSatisfactionPolicy::any_delivered(), + RadrootsTransportSatisfactionClass::Delivered, + ), + ( + RadrootsTransportSatisfactionPolicy::quorum_durable_or_observed(2), + RadrootsTransportSatisfactionClass::DurableOrObserved, + ), + ] { + assert_eq!(policy.target_satisfaction_class(), Some(class)); + } + for policy in [ + RadrootsTransportSatisfactionPolicy::no_wait(), + RadrootsTransportSatisfactionPolicy::any_accepted(), + RadrootsTransportSatisfactionPolicy::all_accepted(), + RadrootsTransportSatisfactionPolicy::quorum_accepted(1), + ] { + assert!(policy.required_target_fingerprints().is_none()); + } + + let required = RadrootsTransportSatisfactionPolicy::required_targets( + RadrootsTransportSatisfactionClass::Accepted, + vec![one.fingerprint.clone(), two.fingerprint.clone()], + ) + .expect("required policy"); + assert_eq!( + required + .required_target_count(1) + .expect_err("required set exceeds total"), + RadrootsTransportError::InvalidSatisfactionPolicy + ); + + let accepted = RadrootsTransportTargetReceipt::new( + one.clone(), + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted), + ); + let rejected = RadrootsTransportTargetReceipt::new( + two.clone(), + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Rejected), + ); + let receipt = RadrootsTransportDeliveryReceipt { + request_id: "delivery".to_owned(), + target_receipts: vec![accepted, rejected], + }; + assert!( + receipt + .is_satisfied_by(&RadrootsTransportSatisfactionPolicy::no_wait()) + .expect("no wait") + ); + assert!( + receipt + .is_satisfied_by(&RadrootsTransportSatisfactionPolicy::any_accepted()) + .expect("any") + ); + assert!( + !receipt + .is_satisfied_by(&RadrootsTransportSatisfactionPolicy::all_accepted()) + .expect("all") + ); + assert!( + receipt + .is_satisfied_by(&RadrootsTransportSatisfactionPolicy::quorum_accepted(1)) + .expect("quorum") + ); + assert!(!receipt.is_satisfied_by(&required).expect("required")); + + let invalid_empty = RadrootsTransportSatisfactionPolicy::RequiredTargets { + class: RadrootsTransportSatisfactionClass::Accepted, + targets: Vec::new(), + }; + assert_eq!( + invalid_empty + .required_target_count(2) + .expect_err("empty required set"), + RadrootsTransportError::EmptyRequiredTargetSet + ); + assert_eq!( + receipt + .is_satisfied_by(&invalid_empty) + .expect_err("empty required receipt policy"), + RadrootsTransportError::EmptyRequiredTargetSet + ); + + let duplicate = RadrootsTransportSatisfactionPolicy::RequiredTargets { + class: RadrootsTransportSatisfactionClass::Accepted, + targets: vec![one.fingerprint.clone(), one.fingerprint], + }; + assert_eq!( + duplicate + .required_target_count(2) + .expect_err("duplicate required set"), + RadrootsTransportError::DuplicateRequiredTargetFingerprint + ); +} + +#[test] +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"); + assert_eq!(signed.payload_kind(), "signed_event_json"); + assert_eq!(mesh.payload_kind(), "mesh_frame_cbor"); + assert_eq!(opaque.payload_kind(), "opaque_bytes"); + + for invalid_id in ["a".repeat(63), "g".repeat(64)] { + assert_eq!( + RadrootsTransportPayload::unchecked_signed_event_json(invalid_id, "{}") + .expect_err("invalid event id"), + RadrootsTransportError::InvalidPayloadId + ); + } + assert_eq!( + RadrootsTransportPayload::mesh_frame_cbor("", [1]).expect_err("empty message id"), + RadrootsTransportError::EmptyPayloadId + ); + for invalid_id in [" mesh", "mesh/one", "mesh\n"] { + assert_eq!( + RadrootsTransportPayload::mesh_frame_cbor(invalid_id, [1]) + .expect_err("invalid token id"), + RadrootsTransportError::InvalidPayloadId + ); + } + assert_eq!( + RadrootsTransportPayload::opaque_bytes(" ", [1]).expect_err("empty label"), + RadrootsTransportError::EmptyPayloadLabel + ); + assert_eq!( + RadrootsTransportPayload::opaque_bytes("label", []).expect_err("empty opaque bytes"), + RadrootsTransportError::EmptyPayloadBytes + ); + assert_eq!( + RadrootsTransportPayload::unchecked_signed_event_json("a".repeat(64), "") + .expect_err("empty raw json"), + RadrootsTransportError::EmptyPayloadBytes + ); + for invalid_json in [" {}", "{} ", "{\n}", "[]", "{", "}"] { + assert_eq!( + RadrootsTransportPayload::unchecked_signed_event_json("a".repeat(64), invalid_json) + .expect_err("invalid raw json"), + RadrootsTransportError::InvalidPayloadBytes + ); + } + for invalid_digest in ["f".repeat(63), "g".repeat(64)] { + assert_eq!( + RadrootsTransportPayload::opaque_bytes_with_digest("label", [1], invalid_digest) + .expect_err("invalid digest"), + RadrootsTransportError::InvalidPayloadDigest + ); + } + + assert_eq!( + RadrootsTransportPayload::unchecked_signed_event_json_with_digest( + "bad", + "{}", + "f".repeat(64), + ) + .expect_err("invalid signed event before digest"), + RadrootsTransportError::InvalidPayloadId + ); + assert_eq!( + RadrootsTransportPayload::unchecked_signed_event_json_with_digest( + "a".repeat(64), + "{}", + "bad", + ) + .expect_err("invalid signed digest"), + RadrootsTransportError::InvalidPayloadDigest + ); + assert_eq!( + RadrootsTransportPayload::mesh_frame_cbor_with_digest("", [1], "f".repeat(64)) + .expect_err("invalid mesh before digest"), + RadrootsTransportError::EmptyPayloadId + ); + assert_eq!( + RadrootsTransportPayload::mesh_frame_cbor_with_digest("mesh", [1], "bad") + .expect_err("invalid mesh digest"), + RadrootsTransportError::InvalidPayloadDigest + ); + assert_eq!( + RadrootsTransportPayload::opaque_bytes_with_digest("", [1], "f".repeat(64)) + .expect_err("invalid opaque payload before digest"), + RadrootsTransportError::EmptyPayloadLabel + ); +} + +#[test] +fn status_contract_covers_builders_and_availability_defaults() { + assert_eq!( + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted) + .with_code("accepted") + .with_message("accepted by transport") + .code + .as_deref(), + Some("accepted") + ); + + assert!(!RadrootsTransportCapabilities::none().deliver); + assert!(RadrootsTransportCapabilities::fetch_only().fetch); + assert_eq!( + RadrootsTransportCapabilities::reticulum_unavailable(), + RadrootsTransportCapabilities::none() + ); + let capabilities = RadrootsTransportCapabilities::deliver_and_fetch() + .with_discovery(true) + .with_gateway_forwarding(true) + .with_receipt_observation(true); + assert!(capabilities.deliver); + assert!(capabilities.fetch); + assert!(capabilities.discovery); + assert!(capabilities.gateway_forwarding); + assert!(capabilities.receipt_observation); + + let unavailable = RadrootsTransportStatus::new( + RadrootsTransportKind::Reticulum, + true, + RadrootsTransportImplementationState::Mock, + false, + "unavailable", + ) + .with_capabilities(capabilities.clone()) + .with_maturity(RadrootsTransportCapabilityMaturity::Preview) + .with_availability(RadrootsTransportCapabilityAvailability::Degraded) + .with_profile_id("reticulum.local") + .with_endpoint_uri(RADROOTS_RETICULUM_ENDPOINT_URI); + assert_eq!( + unavailable.availability, + RadrootsTransportCapabilityAvailability::Degraded + ); + assert_eq!( + unavailable.maturity, + RadrootsTransportCapabilityMaturity::Preview + ); + assert_eq!(unavailable.capabilities, capabilities); + assert!(!unavailable.usable_for_delivery); + + assert!(!RadrootsTransportDeliveryTargetStatus::Accepted.is_ready_for_attempt()); + assert!(!RadrootsTransportDeliveryTargetStatus::Accepted.is_retryable_failure()); + assert!(RadrootsTransportDeliveryTargetStatus::SkippedPolicyDenied.is_terminal_failure()); + assert!(!RadrootsTransportDeliveryTargetStatus::Accepted.is_terminal_failure()); + assert!(!RadrootsTransportDeliveryTargetStatus::Accepted.is_deferred_until_implemented()); +} + +#[test] +#[cfg(feature = "serde")] +fn transport_kind_deserializer_rejects_non_string_values() { + assert!(serde_json::from_str::<RadrootsTransportKind>("1").is_err()); + assert!(serde_json::from_str::<RadrootsTransportKind>("\"NOSTR\"").is_err()); +} + +#[test] +fn reticulum_destination_rejects_wrong_kind_uri_and_missing_scope() { + let local = RadrootsTransportTarget::local("local:memory").expect("local target"); + assert_eq!( + ReticulumDestinationV1::from_target(&local).expect_err("wrong kind"), + RadrootsTransportError::InvalidTargetUri + ); + + let mut wrong_uri = RadrootsTransportTarget::reticulum().expect("Reticulum target"); + wrong_uri.uri = RadrootsTransportTargetUri::parse("reticulum:other").expect("generic URI"); + assert_eq!( + ReticulumDestinationV1::from_target(&wrong_uri).expect_err("wrong URI"), + RadrootsTransportError::InvalidTargetUri + ); + + let mut missing_scope = RadrootsTransportTarget::reticulum().expect("Reticulum target"); + missing_scope.scope = None; + assert_eq!( + ReticulumDestinationV1::from_target(&missing_scope).expect_err("missing scope"), + RadrootsTransportError::EmptyTargetScope + ); +} + +#[test] +fn target_contract_covers_parser_and_authority_boundaries() { + let scope = RadrootsTransportMeshScopeId::parse("farm_1.alpha-beta").expect("scope"); + assert_eq!(scope.as_str(), "farm_1.alpha-beta"); + assert_eq!(scope.to_string(), "farm_1.alpha-beta"); + for invalid_scope in [" scope", "scope ", "scope/path", "scope\n"] { + assert_eq!( + RadrootsTransportMeshScopeId::parse(invalid_scope).expect_err("invalid scope"), + RadrootsTransportError::InvalidTargetScope + ); + } + + 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("\u{7f}").expect_err("control label"), + RadrootsTransportError::InvalidTargetLabel + ); + + for invalid in [ + "", + " wss://relay.example", + "wss://relay example", + "wss://relay\u{7f}.example", + "relay.example", + "ftp://relay.example", + "wss://[::1", + "wss://[]", + "wss://[::1]suffix", + "wss://[[::1]]", + "wss://relay[.example", + "wss://.relay.example", + "wss://relay..example", + "wss://relay.example.", + "wss://relay_example", + "wss://relay.example:", + "wss://[::1]:", + "wss://[::1]:bad", + "wss://[::1]:42949672960", + "ws://[2001:db8::1]", + ] { + assert_eq!( + RadrootsTransportTarget::nostr_relay(invalid).expect_err("invalid relay URI"), + if invalid.is_empty() { + RadrootsTransportError::EmptyTargetUri + } else { + RadrootsTransportError::InvalidTargetUri + }, + "{invalid}" + ); + } + + for (raw, canonical) in [ + ("WSS://[2001:DB8::1]", "wss://[2001:db8::1]"), + ("wss://relay.example:443/", "wss://relay.example:443"), + ("ws://127.0.0.1", "ws://127.0.0.1"), + ] { + assert_eq!( + RadrootsTransportTarget::nostr_relay(raw) + .expect("relay URI") + .uri + .as_str(), + canonical + ); + } +} + +#[test] +fn every_transport_error_has_a_stable_display_message() { + let remaining = [ + RadrootsTransportError::EmptyPayloadId, + RadrootsTransportError::InvalidPayloadId, + RadrootsTransportError::EmptyPayloadLabel, + RadrootsTransportError::InvalidPayloadLabel, + RadrootsTransportError::EmptyPayloadBytes, + RadrootsTransportError::InvalidPayloadBytes, + RadrootsTransportError::InvalidPayloadDigest, + RadrootsTransportError::PayloadDigestMismatch, + ]; + 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 @@ -136,7 +136,7 @@ where publishable.remaining_satisfaction_count, targets.len(), publishable.remaining_required_targets.as_deref(), - )?; + ); let active_delivery_plan_id = publishable.active_delivery_plan_id; let request = RadrootsRelayPublishRequest::new(signed_event.clone(), targets, now_ms) .with_satisfaction_policy(satisfaction_policy) @@ -278,7 +278,7 @@ where )?; let transport_targets = publishable_transport_targets(&publishable)?; let target_set = RadrootsTransportTargetSet::new(transport_targets)?; - let satisfaction_policy = transport_satisfaction_policy_for_publishable(&publishable)?; + let satisfaction_policy = transport_satisfaction_policy_for_publishable(&publishable); let request_id = outbox_publish_idempotency_key( claimed.outbox_event_id, claimed.attempt_count, @@ -731,7 +731,7 @@ fn publishable_transport_targets( fn transport_satisfaction_policy_for_publishable( publishable: &PublishableRelays, -) -> Result<RadrootsTransportSatisfactionPolicy, RadrootsRelayTransportError> { +) -> RadrootsTransportSatisfactionPolicy { satisfaction_policy_for_remaining_count( publishable.satisfaction_class, publishable.remaining_satisfaction_count, @@ -810,17 +810,6 @@ async fn publishable_relays( .iter() .filter(|target| target.delivery_plan_id == active_delivery_plan_id) .collect::<Vec<_>>(); - if let Some(target) = active_targets - .iter() - .find(|target| !is_nostr_target(target) && target.status.is_ready_for_attempt()) - { - return Err(RadrootsRelayTransportError::Transport(format!( - "direct Nostr outbox publish does not accept {} target {} in active delivery plan {}", - target.transport_kind.canonical_label(), - target.endpoint_uri.as_str(), - active_delivery_plan_id - ))); - } let satisfaction_class = plan .satisfaction_policy .target_satisfaction_class() @@ -861,20 +850,18 @@ async fn publishable_relays( let required_for_satisfaction = required_targets .as_ref() .is_some_and(|required| required.contains(&target.endpoint_fingerprint)); - if target - .status - .counts_as_transport_satisfaction(RadrootsTransportSatisfactionClass::Accepted) - && (required_targets.is_none() || required_for_satisfaction) - { + if counts_as_accepted_for_plan( + target.status, + required_targets.is_some(), + required_for_satisfaction, + ) { accepted_count += 1; } let can_contribute_to_satisfaction = required_targets.is_none() || required_for_satisfaction; if remaining_satisfaction_count > 0 && can_contribute_to_satisfaction - && (target.status.is_ready_for_attempt() - || (republish_accepted_relays - && target.status == RadrootsOutboxDeliveryTargetStatus::Accepted)) + && is_publishable_delivery_status(target.status, republish_accepted_relays) { relays.push(PublishableRelay { delivery_target_id: target.delivery_target_id, @@ -904,9 +891,7 @@ async fn publishable_relays( || relays .iter() .any(|relay| relay.delivery_target_id == target.delivery_target_id) - || !(target.status.is_ready_for_attempt() - || (republish_accepted_relays - && target.status == RadrootsOutboxDeliveryTargetStatus::Accepted)) + || !is_publishable_delivery_status(target.status, republish_accepted_relays) { continue; } @@ -949,6 +934,23 @@ fn outbox_publish_idempotency_key( ) } +fn counts_as_accepted_for_plan( + status: RadrootsOutboxDeliveryTargetStatus, + has_required_targets: bool, + required_for_satisfaction: bool, +) -> bool { + status.counts_as_transport_satisfaction(RadrootsTransportSatisfactionClass::Accepted) + && (!has_required_targets || required_for_satisfaction) +} + +fn is_publishable_delivery_status( + status: RadrootsOutboxDeliveryTargetStatus, + republish_accepted_relays: bool, +) -> bool { + status.is_ready_for_attempt() + || (republish_accepted_relays && status == RadrootsOutboxDeliveryTargetStatus::Accepted) +} + fn is_nostr_target(target: &RadrootsOutboxDeliveryTargetRecord) -> bool { target.transport_kind == RadrootsTransportKind::Nostr } @@ -958,39 +960,35 @@ fn satisfaction_policy_for_remaining_count( remaining_satisfaction_count: usize, target_count: usize, exact_required_targets: Option<&[RadrootsTransportTargetFingerprint]>, -) -> Result<RadrootsTransportSatisfactionPolicy, RadrootsRelayTransportError> { +) -> RadrootsTransportSatisfactionPolicy { if let Some(targets) = exact_required_targets { - return RadrootsTransportSatisfactionPolicy::required_targets( - satisfaction_class, - targets.to_vec(), - ) - .map_err(transport_error_to_relay_error); + return RadrootsTransportSatisfactionPolicy::RequiredTargets { + class: satisfaction_class, + targets: targets.to_vec(), + }; } if remaining_satisfaction_count >= target_count { - return Ok(RadrootsTransportSatisfactionPolicy::All { + return RadrootsTransportSatisfactionPolicy::All { class: satisfaction_class, - }); + }; } if remaining_satisfaction_count == 0 { - return Err(RadrootsRelayTransportError::Transport( - "required Nostr relay satisfaction count must be greater than zero".to_owned(), - )); + return RadrootsTransportSatisfactionPolicy::NoWait; } if remaining_satisfaction_count == 1 { - return Ok(RadrootsTransportSatisfactionPolicy::Any { + return RadrootsTransportSatisfactionPolicy::Any { class: satisfaction_class, - }); + }; } - let count = u16::try_from(remaining_satisfaction_count).map_err(|_| { - RadrootsRelayTransportError::Transport( - "required Nostr relay satisfaction count exceeds supported transport policy range" - .to_owned(), - ) - })?; - Ok(RadrootsTransportSatisfactionPolicy::Quorum { + let Ok(count) = u16::try_from(remaining_satisfaction_count) else { + return RadrootsTransportSatisfactionPolicy::All { + class: satisfaction_class, + }; + }; + RadrootsTransportSatisfactionPolicy::Quorum { class: satisfaction_class, threshold: count, - }) + } } async fn ingest_publish_observation( @@ -1019,16 +1017,24 @@ async fn ingest_publish_observation( #[cfg(test)] mod tests { use super::{ - PublishableRelay, PublishableRelays, adapter_transport_failure_receipt, + PublishableRelay, PublishableRelays, RadrootsOutboxDeliveryTargetStatus, + adapter_transport_failure_receipt, counts_as_accepted_for_plan, + is_publishable_delivery_status, publishable_transport_targets, + relay_outcome_from_transport_outcome, relay_outcome_kind_from_code, + relay_outcome_kind_from_transport_outcome, relay_receipts_from_transport_receipts, satisfaction_policy_for_remaining_count, target_receipts_from_relay_receipts, - target_receipts_from_transport_receipts, + target_receipts_from_transport_receipts, transport_error_to_relay_error, + transport_satisfaction_policy_for_publishable, + }; + use crate::{ + RadrootsRelayOutcome, RadrootsRelayOutcomeKind, RadrootsRelayPublishRelayReceipt, + RadrootsRelayTransportError, }; - use crate::{RadrootsRelayOutcome, RadrootsRelayPublishRelayReceipt}; use radroots_transport::{ RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryTargetStatus, - RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportSatisfactionClass, - RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, - RadrootsTransportTargetReceipt, + RadrootsTransportError, RadrootsTransportOutcome, RadrootsTransportOutcomeKind, + RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, + RadrootsTransportTarget, RadrootsTransportTargetReceipt, }; #[test] @@ -1039,8 +1045,7 @@ mod tests { 2, 2, None - ) - .expect("all targets"), + ), RadrootsTransportSatisfactionPolicy::all_accepted() ); assert_eq!( @@ -1049,8 +1054,7 @@ mod tests { 1, 3, None - ) - .expect("at least one"), + ), RadrootsTransportSatisfactionPolicy::any_accepted() ); assert_eq!( @@ -1059,8 +1063,7 @@ mod tests { 2, 3, None - ) - .expect("delivered quorum"), + ), RadrootsTransportSatisfactionPolicy::quorum_delivered(2) ); let required_target = @@ -1071,23 +1074,75 @@ mod tests { 1, 3, Some(core::slice::from_ref(&required_target.fingerprint)) - ) - .expect("exact required targets"), + ), RadrootsTransportSatisfactionPolicy::required_targets( RadrootsTransportSatisfactionClass::Delivered, vec![required_target.fingerprint] ) .expect("required target policy") ); - assert!( + assert_eq!( satisfaction_policy_for_remaining_count( RadrootsTransportSatisfactionClass::Accepted, usize::from(u16::MAX) + 1, usize::from(u16::MAX) + 2, None, - ) - .is_err() + ), + RadrootsTransportSatisfactionPolicy::All { + class: RadrootsTransportSatisfactionClass::Accepted, + } ); + assert_eq!( + satisfaction_policy_for_remaining_count( + RadrootsTransportSatisfactionClass::Accepted, + 0, + 3, + None, + ), + RadrootsTransportSatisfactionPolicy::NoWait + ); + + assert!(counts_as_accepted_for_plan( + RadrootsOutboxDeliveryTargetStatus::Accepted, + false, + false, + )); + assert!(counts_as_accepted_for_plan( + RadrootsOutboxDeliveryTargetStatus::Accepted, + true, + true, + )); + assert!(!counts_as_accepted_for_plan( + RadrootsOutboxDeliveryTargetStatus::Accepted, + true, + false, + )); + assert!(!counts_as_accepted_for_plan( + RadrootsOutboxDeliveryTargetStatus::FailedRetryable, + false, + false, + )); + + assert!(is_publishable_delivery_status( + RadrootsOutboxDeliveryTargetStatus::Pending, + false, + )); + assert!(is_publishable_delivery_status( + RadrootsOutboxDeliveryTargetStatus::FailedRetryable, + false, + )); + assert!(is_publishable_delivery_status( + RadrootsOutboxDeliveryTargetStatus::Accepted, + true, + )); + assert!(!is_publishable_delivery_status( + RadrootsOutboxDeliveryTargetStatus::Accepted, + false, + )); + assert!(!is_publishable_delivery_status( + RadrootsOutboxDeliveryTargetStatus::FailedTerminal, + true, + )); } #[test] @@ -1167,4 +1222,258 @@ mod tests { assert!(!receipt.quorum_met); assert!(receipt.relays.iter().all(|relay| relay.attempted)); } + + #[test] + fn transport_outcomes_preserve_relay_semantics() { + let cases = [ + ( + RadrootsTransportOutcomeKind::Accepted, + RadrootsRelayOutcomeKind::Accepted, + ), + ( + RadrootsTransportOutcomeKind::DuplicateAccepted, + RadrootsRelayOutcomeKind::DuplicateAccepted, + ), + ( + RadrootsTransportOutcomeKind::Delivered, + RadrootsRelayOutcomeKind::Accepted, + ), + ( + RadrootsTransportOutcomeKind::Forwarded, + RadrootsRelayOutcomeKind::Accepted, + ), + ( + RadrootsTransportOutcomeKind::StoredByGateway, + RadrootsRelayOutcomeKind::Accepted, + ), + ( + RadrootsTransportOutcomeKind::Seen, + RadrootsRelayOutcomeKind::Accepted, + ), + ( + RadrootsTransportOutcomeKind::DeferredUntilImplemented, + RadrootsRelayOutcomeKind::Unsupported, + ), + ( + RadrootsTransportOutcomeKind::Rejected, + RadrootsRelayOutcomeKind::Invalid, + ), + ( + RadrootsTransportOutcomeKind::RouteUnavailable, + RadrootsRelayOutcomeKind::RelayUrlRejected, + ), + ( + RadrootsTransportOutcomeKind::PayloadTooLarge, + RadrootsRelayOutcomeKind::Invalid, + ), + ( + RadrootsTransportOutcomeKind::PolicyDenied, + RadrootsRelayOutcomeKind::Restricted, + ), + ( + RadrootsTransportOutcomeKind::Timeout, + RadrootsRelayOutcomeKind::Timeout, + ), + ( + RadrootsTransportOutcomeKind::ConnectionFailed, + RadrootsRelayOutcomeKind::ConnectionFailed, + ), + ( + RadrootsTransportOutcomeKind::TransportUnavailable, + RadrootsRelayOutcomeKind::Error, + ), + ]; + for (transport_kind, relay_kind) in cases { + assert_eq!( + relay_outcome_kind_from_transport_outcome(transport_kind), + relay_kind + ); + let outcome = RadrootsTransportOutcome::new(transport_kind) + .with_message(format!("{transport_kind:?}")); + let relay_outcome = relay_outcome_from_transport_outcome(&outcome); + assert_eq!(relay_outcome.kind, relay_kind); + assert_eq!(relay_outcome.message, outcome.message); + } + + let code_cases = [ + ("accepted", RadrootsRelayOutcomeKind::Accepted), + ( + "duplicate_accepted", + RadrootsRelayOutcomeKind::DuplicateAccepted, + ), + ("blocked", RadrootsRelayOutcomeKind::Blocked), + ("rate_limited", RadrootsRelayOutcomeKind::RateLimited), + ("invalid", RadrootsRelayOutcomeKind::Invalid), + ("pow_required", RadrootsRelayOutcomeKind::PowRequired), + ("restricted", RadrootsRelayOutcomeKind::Restricted), + ("auth_required", RadrootsRelayOutcomeKind::AuthRequired), + ("muted", RadrootsRelayOutcomeKind::Muted), + ("unsupported", RadrootsRelayOutcomeKind::Unsupported), + ( + "payment_required", + RadrootsRelayOutcomeKind::PaymentRequired, + ), + ("error", RadrootsRelayOutcomeKind::Error), + ("timeout", RadrootsRelayOutcomeKind::Timeout), + ( + "connection_failed", + RadrootsRelayOutcomeKind::ConnectionFailed, + ), + ( + "relay_url_rejected", + RadrootsRelayOutcomeKind::RelayUrlRejected, + ), + ( + "skipped_already_accepted", + RadrootsRelayOutcomeKind::SkippedAlreadyAccepted, + ), + ("unknown", RadrootsRelayOutcomeKind::Unknown), + ]; + for (code, relay_kind) in code_cases { + assert_eq!(relay_outcome_kind_from_code(code), Some(relay_kind)); + assert_eq!( + relay_outcome_from_transport_outcome( + &RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Rejected) + .with_code(code) + ) + .kind, + relay_kind + ); + } + assert_eq!(relay_outcome_kind_from_code("unrecognized"), None); + assert_eq!( + relay_outcome_from_transport_outcome( + &RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Seen) + .with_code("unrecognized") + ) + .kind, + RadrootsRelayOutcomeKind::Accepted + ); + + for kind in [ + RadrootsRelayOutcomeKind::RateLimited, + RadrootsRelayOutcomeKind::Error, + RadrootsRelayOutcomeKind::Unknown, + ] { + assert_eq!( + kind.transport_outcome_kind(), + RadrootsTransportOutcomeKind::TransportUnavailable + ); + } + } + + #[test] + fn transport_target_and_error_adapters_preserve_contract_categories() { + let target = RadrootsTransportTarget::nostr_relay("wss://relay.example").expect("target"); + let publishable = PublishableRelays { + active_delivery_plan_id: 7, + relays: vec![PublishableRelay { + delivery_target_id: 11, + relay_url: target.uri.as_str().to_owned(), + endpoint_fingerprint: target.fingerprint.clone(), + target_scope: Some("foodshed.west".to_owned()), + target_label: Some("primary relay".to_owned()), + }], + accepted_count: 0, + satisfied_count: 0, + satisfaction_required_count: 1, + remaining_satisfaction_count: 1, + satisfaction_class: RadrootsTransportSatisfactionClass::Accepted, + required_targets: None, + remaining_required_targets: None, + }; + let targets = publishable_transport_targets(&publishable).expect("transport targets"); + assert_eq!(targets.len(), 1); + assert_eq!( + targets[0].scope.as_ref().expect("scope").as_str(), + "foodshed.west" + ); + assert_eq!( + targets[0].label.as_ref().expect("label").as_str(), + "primary relay" + ); + assert_eq!( + transport_satisfaction_policy_for_publishable(&publishable), + RadrootsTransportSatisfactionPolicy::all_accepted() + ); + + let mut invalid = publishable; + invalid.relays[0].target_scope = Some("bad scope".to_owned()); + assert!(matches!( + publishable_transport_targets(&invalid), + Err(RadrootsRelayTransportError::Transport(_)) + )); + invalid.relays[0].target_scope = Some("foodshed.west".to_owned()); + invalid.relays[0].target_label = Some("bad\0label".to_owned()); + assert!(matches!( + publishable_transport_targets(&invalid), + Err(RadrootsRelayTransportError::Transport(_)) + )); + invalid.relays[0].target_label = Some("primary relay".to_owned()); + invalid.relays[0].relay_url = "not-a-relay".to_owned(); + assert!(matches!( + publishable_transport_targets(&invalid), + Err(RadrootsRelayTransportError::TransportContract(_)) + )); + + let generic_errors = [ + RadrootsTransportError::UnsupportedOperation, + RadrootsTransportError::EmptyTransportKind, + RadrootsTransportError::InvalidTransportKind, + RadrootsTransportError::EmptyTargetScope, + RadrootsTransportError::InvalidTargetScope, + RadrootsTransportError::EmptyTargetLabel, + RadrootsTransportError::InvalidTargetLabel, + RadrootsTransportError::InvalidSatisfactionPolicy, + RadrootsTransportError::EmptyRequiredTargetSet, + RadrootsTransportError::DuplicateRequiredTargetFingerprint, + ]; + for error in generic_errors { + assert!(matches!( + transport_error_to_relay_error(error), + RadrootsRelayTransportError::Transport(_) + )); + } + let target_errors = [ + RadrootsTransportError::EmptyTargetUri, + RadrootsTransportError::InvalidTargetUri, + RadrootsTransportError::EmptyTargetSet, + RadrootsTransportError::DuplicateTargetFingerprint, + RadrootsTransportError::InvalidTargetFingerprint, + ]; + for error in target_errors { + assert!(matches!( + transport_error_to_relay_error(error), + RadrootsRelayTransportError::TransportContract(_) + )); + } + let payload_errors = [ + RadrootsTransportError::EmptyPayloadId, + RadrootsTransportError::InvalidPayloadId, + RadrootsTransportError::EmptyPayloadLabel, + RadrootsTransportError::InvalidPayloadLabel, + RadrootsTransportError::EmptyPayloadBytes, + RadrootsTransportError::InvalidPayloadBytes, + RadrootsTransportError::InvalidPayloadDigest, + RadrootsTransportError::PayloadDigestMismatch, + ]; + for error in payload_errors { + assert!(matches!( + transport_error_to_relay_error(error), + RadrootsRelayTransportError::NostrEventJson(_) + )); + } + + let unknown = + RadrootsTransportTarget::nostr_relay("wss://unknown.example").expect("unknown target"); + let delivery = RadrootsTransportDeliveryReceipt { + request_id: "unknown".to_owned(), + target_receipts: vec![RadrootsTransportTargetReceipt::new( + unknown, + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted), + )], + }; + assert!(target_receipts_from_transport_receipts(&invalid, &delivery).is_empty()); + assert_eq!(relay_receipts_from_transport_receipts(&delivery).len(), 1); + } } diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs @@ -112,27 +112,12 @@ pub trait RadrootsRelayPublishAdapter: Send + Sync { pub fn verified_signed_event_payload( signed_event: &RadrootsSignedEvent, ) -> Result<RadrootsTransportPayload, RadrootsTransportError> { - verify_signed_event_raw_json_matches_event(signed_event)?; RadrootsTransportPayload::unchecked_signed_event_json( signed_event.id_str(), signed_event.raw_json(), ) } -fn verify_signed_event_raw_json_matches_event( - signed_event: &RadrootsSignedEvent, -) -> Result<(), RadrootsTransportError> { - let wire = RadrootsNip01EventWire::parse_json(signed_event.raw_json()) - .map_err(|_| RadrootsTransportError::InvalidPayloadBytes)?; - if wire.id != signed_event.id_str() { - return Err(RadrootsTransportError::InvalidPayloadId); - } - if &wire != signed_event.wire() { - return Err(RadrootsTransportError::InvalidPayloadBytes); - } - Ok(()) -} - impl<A> RadrootsRelayPublishAdapter for &A where A: RadrootsRelayPublishAdapter + ?Sized, @@ -266,6 +251,114 @@ fn nostr_error_to_transport_error(error: RadrootsRelayTransportError) -> Radroot } } +#[cfg(test)] +mod contract_tests { + use super::nostr_error_to_transport_error; + use crate::RadrootsRelayTransportError; + use radroots_transport::RadrootsTransportError; + + #[test] + fn relay_errors_map_to_stable_transport_contract_categories() { + assert_eq!( + nostr_error_to_transport_error(RadrootsRelayTransportError::TransportContract( + "contract".to_owned(), + )), + RadrootsTransportError::InvalidPayloadBytes + ); + + let target_errors = [ + RadrootsRelayTransportError::RelayUrlParse { + url: "bad".to_owned(), + reason: "parse".to_owned(), + }, + RadrootsRelayTransportError::WsRequiresLocalhostPolicy { + url: "ws://relay.example".to_owned(), + }, + RadrootsRelayTransportError::UnsupportedRelayScheme { + url: "https://relay.example".to_owned(), + scheme: "https".to_owned(), + }, + RadrootsRelayTransportError::EmptyRelayHost { + url: "wss://".to_owned(), + }, + RadrootsRelayTransportError::RelayUrlUserinfo { + url: "wss://user@relay.example".to_owned(), + }, + RadrootsRelayTransportError::RelayUrlQueryOrFragment { + url: "wss://relay.example?x=1".to_owned(), + }, + RadrootsRelayTransportError::RelayUrlForbiddenDestination { + url: "wss://127.0.0.1".to_owned(), + reason: "loopback".to_owned(), + }, + RadrootsRelayTransportError::RelayUrlResolvedForbiddenDestination { + url: "wss://relay.example".to_owned(), + address: "127.0.0.1".to_owned(), + reason: "loopback".to_owned(), + }, + RadrootsRelayTransportError::EmptyTargetSet, + ]; + for error in target_errors { + assert_eq!( + nostr_error_to_transport_error(error), + RadrootsTransportError::InvalidTargetUri + ); + } + + assert_eq!( + nostr_error_to_transport_error(RadrootsRelayTransportError::NostrEventJson( + "event".to_owned(), + )), + RadrootsTransportError::InvalidPayloadBytes + ); + let json_error = serde_json::from_str::<serde_json::Value>("{").expect_err("invalid json"); + assert_eq!( + nostr_error_to_transport_error(RadrootsRelayTransportError::Json(json_error)), + RadrootsTransportError::InvalidPayloadBytes + ); + assert_eq!( + nostr_error_to_transport_error(RadrootsRelayTransportError::Transport( + "offline".to_owned(), + )), + RadrootsTransportError::InvalidTransportKind + ); + assert_eq!( + nostr_error_to_transport_error(RadrootsRelayTransportError::EmptyFetchFilters), + RadrootsTransportError::InvalidTransportKind + ); + assert_eq!( + nostr_error_to_transport_error(RadrootsRelayTransportError::InvalidFetchLimit { + field: "max_events", + }), + RadrootsTransportError::InvalidTransportKind + ); + + #[cfg(feature = "storage")] + { + assert_eq!( + nostr_error_to_transport_error(RadrootsRelayTransportError::EventStore( + radroots_event_store::RadrootsEventStoreError::MissingEvent( + "missing".to_owned(), + ), + )), + RadrootsTransportError::InvalidTransportKind + ); + assert_eq!( + nostr_error_to_transport_error(RadrootsRelayTransportError::Outbox( + radroots_outbox::RadrootsOutboxError::EventNotFound(1), + )), + RadrootsTransportError::InvalidTransportKind + ); + assert_eq!( + nostr_error_to_transport_error( + RadrootsRelayTransportError::MissingSignedOutboxEvent(1), + ), + RadrootsTransportError::InvalidTransportKind + ); + } + } +} + fn signed_event_from_transport_payload( payload: &RadrootsTransportPayload, ) -> Result<RadrootsSignedEvent, RadrootsTransportError> { diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -17,11 +17,13 @@ use radroots_outbox::{ RadrootsOutboxOperationStatus, }; use radroots_transport::{ - RadrootsTransport, RadrootsTransportDeliveryRequest, RadrootsTransportError, - RadrootsTransportFetchRequest, RadrootsTransportKind, RadrootsTransportMeshScopeId, - RadrootsTransportPayload, RadrootsTransportSatisfactionClass, - RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetLabel, - RadrootsTransportTargetSet, + RadrootsTransport, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, + RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportFetchReceipt, + RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState, + RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportOutcome, + RadrootsTransportOutcomeKind, RadrootsTransportPayload, RadrootsTransportSatisfactionClass, + RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget, + RadrootsTransportTargetLabel, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, }; use radroots_transport_nostr::{ RadrootsMockRelayFetchAdapter, RadrootsMockRelayPublishAdapter, RadrootsNostrTransport, @@ -31,7 +33,8 @@ use radroots_transport_nostr::{ RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTargetSet, RadrootsRelayTransportError, RadrootsRelayUrl, RadrootsRelayUrlPolicy, fetch_and_ingest_relay_events, fetch_relay_events, fetch_relay_events_blocking, - publish_claimed_outbox_event, publish_signed_event, verified_signed_event_payload, + publish_claimed_outbox_event, publish_claimed_outbox_event_with_transport, + publish_signed_event, verified_signed_event_payload, }; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; @@ -139,6 +142,62 @@ impl RadrootsRelayPublishAdapter for UnknownRelayReceiptPublishAdapter { } } +#[derive(Clone)] +struct ScriptedTransport { + outcomes: Vec<RadrootsTransportOutcome>, +} + +impl ScriptedTransport { + fn new(outcomes: Vec<RadrootsTransportOutcome>) -> Self { + Self { outcomes } + } +} + +impl RadrootsTransport for ScriptedTransport { + fn transport_kind(&self) -> RadrootsTransportKind { + RadrootsTransportKind::Nostr + } + + fn status<'a>(&'a self) -> RadrootsTransportFuture<'a, RadrootsTransportStatus> { + Box::pin(async { + Ok(RadrootsTransportStatus::new( + RadrootsTransportKind::Nostr, + true, + RadrootsTransportImplementationState::Real, + true, + "scripted", + )) + }) + } + + fn deliver<'a>( + &'a self, + request: RadrootsTransportDeliveryRequest, + ) -> RadrootsTransportFuture<'a, RadrootsTransportDeliveryReceipt> { + Box::pin(async move { + let target_receipts = request + .target_set + .targets() + .iter() + .cloned() + .zip(self.outcomes.iter().cloned()) + .map(|(target, outcome)| RadrootsTransportTargetReceipt::new(target, outcome)) + .collect(); + Ok(RadrootsTransportDeliveryReceipt { + request_id: request.request_id, + target_receipts, + }) + }) + } + + fn fetch<'a>( + &'a self, + _request: RadrootsTransportFetchRequest, + ) -> RadrootsTransportFuture<'a, RadrootsTransportFetchReceipt> { + Box::pin(async { Err(RadrootsTransportError::UnsupportedOperation) }) + } +} + fn fixture_keys() -> RadrootsNostrKeys { let secret_key = RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key"); @@ -728,7 +787,16 @@ async fn mock_publish_preserves_exact_raw_json_and_counts_outcomes() { async fn nostr_transport_facade_delivers_signed_event_payloads() { let signed = signed_post("facade payload"); let adapter = RadrootsMockRelayPublishAdapter::new(); - let transport = RadrootsNostrTransport::new(&adapter); + let expected_status = RadrootsTransportStatus::new( + RadrootsTransportKind::Nostr, + true, + RadrootsTransportImplementationState::Real, + true, + "fixture ready", + ); + let transport = RadrootsNostrTransport::new(&adapter).with_status(expected_status.clone()); + assert_eq!(transport.transport_kind(), RadrootsTransportKind::Nostr); + assert!(transport.adapter().captured_raw_events().is_empty()); let target = nostr_target(RELAY_PRIMARY_WSS); let request = RadrootsTransportDeliveryRequest::new( "facade-request-1", @@ -745,8 +813,7 @@ async fn nostr_transport_facade_delivers_signed_event_payloads() { adapter.captured_raw_events(), vec![signed.raw_json().to_owned()] ); - assert!(status.capabilities.deliver); - assert!(!status.capabilities.fetch); + assert_eq!(status, expected_status); assert_eq!(receipt.request_id, "facade-request-1"); assert_eq!(receipt.target_receipts.len(), 1); assert_eq!(receipt.target_receipts[0].target, target); @@ -827,6 +894,154 @@ async fn nostr_transport_facade_rejects_unsupported_payloads_and_targets() { .await .expect_err("target rejected"); assert_eq!(target_error, RadrootsTransportError::InvalidTargetUri); + + let invalid_json_error = transport + .deliver(RadrootsTransportDeliveryRequest::new( + "facade-request-invalid-json", + RadrootsTransportPayload::SignedEventJson { + event_id: signed.id_str().to_owned(), + raw_json: "{}".to_owned(), + digest: "00".repeat(32), + }, + RadrootsTransportTargetSet::new(vec![nostr_target(RELAY_PRIMARY_WSS)]) + .expect("targets"), + RadrootsTransportSatisfactionPolicy::all_accepted(), + )) + .await + .expect_err("invalid event json rejected"); + assert_eq!( + invalid_json_error, + RadrootsTransportError::InvalidPayloadBytes + ); + + let mismatched_id_error = transport + .deliver(RadrootsTransportDeliveryRequest::new( + "facade-request-mismatched-id", + RadrootsTransportPayload::SignedEventJson { + event_id: "00".repeat(32), + raw_json: signed.raw_json().to_owned(), + digest: "00".repeat(32), + }, + RadrootsTransportTargetSet::new(vec![nostr_target(RELAY_PRIMARY_WSS)]) + .expect("targets"), + RadrootsTransportSatisfactionPolicy::all_accepted(), + )) + .await + .expect_err("mismatched event id rejected"); + assert_eq!( + mismatched_id_error, + RadrootsTransportError::InvalidPayloadId + ); + + let mut tampered = + serde_json::from_str::<serde_json::Value>(signed.raw_json()).expect("signed event json"); + tampered["content"] = serde_json::Value::String("tampered".to_owned()); + let tampered_raw = serde_json::to_string(&tampered).expect("tampered event json"); + let tampered_error = transport + .deliver(RadrootsTransportDeliveryRequest::new( + "facade-request-tampered-event", + RadrootsTransportPayload::SignedEventJson { + event_id: signed.id_str().to_owned(), + raw_json: tampered_raw, + digest: "00".repeat(32), + }, + RadrootsTransportTargetSet::new(vec![nostr_target(RELAY_PRIMARY_WSS)]) + .expect("targets"), + RadrootsTransportSatisfactionPolicy::all_accepted(), + )) + .await + .expect_err("tampered event rejected"); + assert_eq!(tampered_error, RadrootsTransportError::InvalidPayloadBytes); + + let forbidden_target_error = transport + .deliver(RadrootsTransportDeliveryRequest::new( + "facade-request-forbidden-target", + RadrootsTransportPayload::unchecked_signed_event_json( + signed.id_str(), + signed.raw_json(), + ) + .expect("payload"), + RadrootsTransportTargetSet::new(vec![nostr_target("wss://127.0.0.1")]) + .expect("targets"), + RadrootsTransportSatisfactionPolicy::all_accepted(), + )) + .await + .expect_err("forbidden relay rejected"); + assert_eq!( + forbidden_target_error, + RadrootsTransportError::InvalidTargetUri + ); + + let local_receipt = transport + .deliver(RadrootsTransportDeliveryRequest::new( + "facade-request-local-relay", + RadrootsTransportPayload::unchecked_signed_event_json( + signed.id_str(), + signed.raw_json(), + ) + .expect("payload"), + RadrootsTransportTargetSet::new(vec![nostr_target("ws://127.0.0.1:21002")]) + .expect("targets"), + RadrootsTransportSatisfactionPolicy::all_accepted(), + )) + .await + .expect("localhost relay accepted"); + assert_eq!(local_receipt.target_receipts.len(), 1); +} + +#[tokio::test] +async fn nostr_transport_facade_preserves_adapter_failure_and_omission_evidence() { + let signed = signed_post("facade failures"); + let payload = + RadrootsTransportPayload::unchecked_signed_event_json(signed.id_str(), signed.raw_json()) + .expect("payload"); + let targets = RadrootsTransportTargetSet::new(vec![ + nostr_target(RELAY_PRIMARY_WSS), + nostr_target(RELAY_SECONDARY_WSS), + ]) + .expect("targets"); + + let transport = RadrootsNostrTransport::new(TransportFailurePublishAdapter); + let failed = transport + .deliver(RadrootsTransportDeliveryRequest::new( + "facade-transport-failure", + payload.clone(), + targets.clone(), + RadrootsTransportSatisfactionPolicy::all_accepted(), + )) + .await + .expect("failure receipts"); + assert_eq!(failed.target_receipts.len(), 2); + assert!(failed.target_receipts.iter().all(|receipt| { + receipt.outcome.kind == RadrootsTransportOutcomeKind::ConnectionFailed + && receipt.status == RadrootsTransportDeliveryTargetStatus::FailedRetryable + })); + + let partial = RadrootsNostrTransport::new(PartialPublishAdapter) + .deliver(RadrootsTransportDeliveryRequest::new( + "facade-partial", + payload.clone(), + targets.clone(), + RadrootsTransportSatisfactionPolicy::all_accepted(), + )) + .await + .expect("partial receipts"); + assert_eq!(partial.target_receipts.len(), 2); + assert_eq!( + partial.target_receipts[1].outcome.kind, + RadrootsTransportOutcomeKind::RouteUnavailable + ); + + let error = RadrootsNostrTransport::new(NostrJsonFailurePublishAdapter) + .deliver(RadrootsTransportDeliveryRequest::new( + "facade-json-failure", + payload, + targets, + RadrootsTransportSatisfactionPolicy::all_accepted(), + )) + .await + .expect_err("adapter JSON error"); + assert_eq!(error, RadrootsTransportError::InvalidPayloadBytes); } #[tokio::test] @@ -1002,7 +1217,7 @@ async fn publish_all_policy_uses_requested_target_count() { let receipt = publish_signed_event( &PartialPublishAdapter, - RadrootsRelayPublishRequest::new(signed, targets, 1_080) + RadrootsRelayPublishRequest::new(signed.clone(), targets.clone(), 1_080) .with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::all_accepted()), ) .await @@ -1012,6 +1227,16 @@ async fn publish_all_policy_uses_requested_target_count() { assert_eq!(receipt.accepted_count, 1); assert_eq!(receipt.quorum, 2); assert!(!receipt.quorum_met); + + let no_wait = publish_signed_event( + &PartialPublishAdapter, + RadrootsRelayPublishRequest::new(signed, targets, 1_081) + .with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::NoWait), + ) + .await + .expect("no-wait publish"); + assert_eq!(no_wait.quorum, 0); + assert!(no_wait.quorum_met); } #[test] @@ -1753,6 +1978,424 @@ async fn outbox_publish_persists_partial_success_and_skips_accepted_retry() { } #[tokio::test] +async fn outbox_transport_facade_persists_partial_success_and_retryable_failures() { + let signed = signed_post("transport facade outbox"); + let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); + let store = RadrootsEventStore::open_memory().await.expect("store"); + let draft = RadrootsEventDraft::new( + "radroots.social.post.v1", + KIND_POST, + signed.created_at(), + signed.tags_as_vec(), + signed.content().to_owned(), + signed.pubkey_str(), + ) + .expect("draft"); + let receipt = outbox + .enqueue_operation(all_accepted_outbox_operation_input( + draft, + [RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS], + )) + .await + .expect("enqueue"); + let claimed = outbox + .claim_next_ready_event("signer", "transport-sign", 2_000, 1_000) + .await + .expect("sign claim") + .expect("sign claim"); + let signed = complete_claimed_signing(&outbox, &claimed, 1_100).await; + let publish_claim = outbox + .claim_next_ready_event("publisher", "transport-publish", 3_000, 1_100) + .await + .expect("publish claim") + .expect("publish claim"); + + let adapter = RadrootsMockRelayPublishAdapter::new() + .with_outcome(RELAY_PRIMARY_WSS, RadrootsRelayOutcome::accepted()) + .with_outcome( + RELAY_SECONDARY_WSS, + RadrootsRelayOutcome::timeout("timeout: transport facade"), + ); + let transport = RadrootsNostrTransport::new(adapter); + let published = publish_claimed_outbox_event_with_transport( + &outbox, + &store, + &transport, + &publish_claim, + RadrootsOutboxPublishPolicy::new(2_500), + 2_200, + ) + .await + .expect("transport publish"); + + assert_eq!(published.event_id, signed.id_str()); + assert_eq!(published.attempted_count, 2); + assert_eq!(published.accepted_count, 1); + assert_eq!(published.retryable_count, 1); + assert_eq!(published.terminal_count, 0); + assert!(!published.quorum_met); + assert_eq!(published.relay_receipts.len(), 2); + let targets = outbox + .delivery_targets(receipt.outbox_event_id) + .await + .expect("targets"); + assert_eq!( + targets + .iter() + .find(|target| target.endpoint_uri.as_str() == RELAY_PRIMARY_WSS) + .expect("primary") + .status, + RadrootsOutboxDeliveryTargetStatus::Accepted + ); + assert_eq!( + targets + .iter() + .find(|target| target.endpoint_uri.as_str() == RELAY_SECONDARY_WSS) + .expect("secondary") + .status, + RadrootsOutboxDeliveryTargetStatus::FailedRetryable + ); + let event = outbox + .get_event(receipt.outbox_event_id) + .await + .expect("event") + .expect("event"); + assert_eq!(event.state, RadrootsOutboxEventState::PublishRetryable); + let observations = store + .observations_for_event(signed.id_str()) + .await + .expect("observations"); + assert_outbox_publish_observations(&observations, 1); +} + +#[tokio::test] +async fn outbox_transport_facade_persists_every_delivery_status() { + let signed = signed_post("transport outcome matrix"); + let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); + let store = RadrootsEventStore::open_memory().await.expect("store"); + let draft = RadrootsEventDraft::new( + "radroots.social.post.v1", + KIND_POST, + signed.created_at(), + signed.tags_as_vec(), + signed.content().to_owned(), + signed.pubkey_str(), + ) + .expect("draft"); + let relays = (0..14) + .map(|index| format!("wss://relay-{index}.example.com")) + .collect::<Vec<_>>(); + let receipt = outbox + .enqueue_operation(all_accepted_outbox_operation_input(draft, &relays)) + .await + .expect("enqueue"); + let claimed = outbox + .claim_next_ready_event("signer", "matrix-sign", 2_000, 1_000) + .await + .expect("sign claim") + .expect("sign claim"); + let signed = complete_claimed_signing(&outbox, &claimed, 1_100).await; + let publish_claim = outbox + .claim_next_ready_event("publisher", "matrix-publish", 3_000, 1_100) + .await + .expect("publish claim") + .expect("publish claim"); + let outcomes = [ + RadrootsTransportOutcomeKind::Accepted, + RadrootsTransportOutcomeKind::DuplicateAccepted, + RadrootsTransportOutcomeKind::Delivered, + RadrootsTransportOutcomeKind::Forwarded, + RadrootsTransportOutcomeKind::StoredByGateway, + RadrootsTransportOutcomeKind::Seen, + RadrootsTransportOutcomeKind::DeferredUntilImplemented, + RadrootsTransportOutcomeKind::Rejected, + RadrootsTransportOutcomeKind::RouteUnavailable, + RadrootsTransportOutcomeKind::PayloadTooLarge, + RadrootsTransportOutcomeKind::PolicyDenied, + RadrootsTransportOutcomeKind::Timeout, + RadrootsTransportOutcomeKind::ConnectionFailed, + RadrootsTransportOutcomeKind::TransportUnavailable, + ] + .into_iter() + .map(RadrootsTransportOutcome::new) + .collect(); + let transport = ScriptedTransport::new(outcomes); + + let published = publish_claimed_outbox_event_with_transport( + &outbox, + &store, + &transport, + &publish_claim, + RadrootsOutboxPublishPolicy::new(2_500), + 2_200, + ) + .await + .expect("transport publish"); + + assert_eq!(published.event_id, signed.id_str()); + assert_eq!(published.attempted_count, 13); + assert_eq!(published.accepted_count, 6); + assert_eq!(published.retryable_count, 3); + assert_eq!(published.terminal_count, 5); + assert!(!published.quorum_met); + assert_eq!(published.target_receipts.len(), 14); + assert_eq!(published.relay_receipts.len(), 14); + let targets = outbox + .delivery_targets(receipt.outbox_event_id) + .await + .expect("targets"); + let expected_statuses = [ + RadrootsOutboxDeliveryTargetStatus::Accepted, + RadrootsOutboxDeliveryTargetStatus::Accepted, + RadrootsOutboxDeliveryTargetStatus::Delivered, + RadrootsOutboxDeliveryTargetStatus::Forwarded, + RadrootsOutboxDeliveryTargetStatus::StoredByGateway, + RadrootsOutboxDeliveryTargetStatus::Seen, + RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented, + RadrootsOutboxDeliveryTargetStatus::FailedTerminal, + RadrootsOutboxDeliveryTargetStatus::FailedTerminal, + RadrootsOutboxDeliveryTargetStatus::FailedTerminal, + RadrootsOutboxDeliveryTargetStatus::SkippedPolicyDenied, + RadrootsOutboxDeliveryTargetStatus::FailedRetryable, + RadrootsOutboxDeliveryTargetStatus::FailedRetryable, + RadrootsOutboxDeliveryTargetStatus::FailedRetryable, + ]; + assert_eq!(targets.len(), expected_statuses.len()); + for (target, expected_status) in targets.iter().zip(expected_statuses) { + assert_eq!(target.status, expected_status); + } + let event = outbox + .get_event(receipt.outbox_event_id) + .await + .expect("event") + .expect("event"); + assert_eq!(event.state, RadrootsOutboxEventState::PublishRetryable); + let observations = store + .observations_for_event(signed.id_str()) + .await + .expect("observations"); + assert_outbox_publish_observations(&observations, 6); +} + +#[tokio::test] +async fn outbox_transport_facade_rejects_pending_receipts() { + let signed = signed_post("pending transport receipt"); + let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); + let store = RadrootsEventStore::open_memory().await.expect("store"); + let draft = RadrootsEventDraft::new( + "radroots.social.post.v1", + KIND_POST, + signed.created_at(), + signed.tags_as_vec(), + signed.content().to_owned(), + signed.pubkey_str(), + ) + .expect("draft"); + outbox + .enqueue_operation(all_accepted_outbox_operation_input( + draft, + [RELAY_PRIMARY_WSS], + )) + .await + .expect("enqueue"); + let claimed = outbox + .claim_next_ready_event("signer", "pending-sign", 2_000, 1_000) + .await + .expect("sign claim") + .expect("sign claim"); + complete_claimed_signing(&outbox, &claimed, 1_100).await; + let publish_claim = outbox + .claim_next_ready_event("publisher", "pending-publish", 3_000, 1_100) + .await + .expect("publish claim") + .expect("publish claim"); + let transport = ScriptedTransport::new(vec![ + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted) + .with_target_status(RadrootsTransportDeliveryTargetStatus::Pending), + ]); + + let error = publish_claimed_outbox_event_with_transport( + &outbox, + &store, + &transport, + &publish_claim, + RadrootsOutboxPublishPolicy::new(2_500), + 2_200, + ) + .await + .expect_err("pending receipt rejected"); + assert!(matches!( + error, + RadrootsRelayTransportError::TransportContract(_) + )); +} + +#[tokio::test] +async fn outbox_transport_facade_handles_empty_and_invalid_claim_plans() { + let signed = signed_post("transport plan edges"); + let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); + let store = RadrootsEventStore::open_memory().await.expect("store"); + let draft = RadrootsEventDraft::new( + "radroots.social.post.v1", + KIND_POST, + signed.created_at(), + signed.tags_as_vec(), + signed.content().to_owned(), + signed.pubkey_str(), + ) + .expect("draft"); + let receipt = outbox + .enqueue_operation(all_accepted_outbox_operation_input( + draft, + [RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS], + )) + .await + .expect("enqueue"); + let claimed = outbox + .claim_next_ready_event("signer", "plan-sign", 2_000, 1_000) + .await + .expect("sign claim") + .expect("sign claim"); + let signed = complete_claimed_signing(&outbox, &claimed, 1_100).await; + let publish_claim = outbox + .claim_next_ready_event("publisher", "plan-publish", 3_000, 1_100) + .await + .expect("publish claim") + .expect("publish claim"); + for target in &publish_claim.delivery_targets { + outbox + .mark_delivery_target_accepted( + publish_claim.outbox_event_id, + publish_claim.claim_token.as_str(), + target.delivery_target_id, + 2_150, + ) + .await + .expect("accepted target"); + } + let published = publish_claimed_outbox_event_with_transport( + &outbox, + &store, + &ScriptedTransport::new(Vec::new()), + &publish_claim, + RadrootsOutboxPublishPolicy::new(2_500), + 2_200, + ) + .await + .expect("already satisfied publish"); + assert_eq!(published.event_id, signed.id_str()); + assert_eq!(published.attempted_count, 0); + assert_eq!(published.accepted_count, 2); + assert!(published.quorum_met); + + let second_signed = signed_post("invalid claimed plan"); + let second_draft = RadrootsEventDraft::new( + "radroots.social.post.v1", + KIND_POST, + second_signed.created_at(), + second_signed.tags_as_vec(), + second_signed.content().to_owned(), + second_signed.pubkey_str(), + ) + .expect("draft"); + outbox + .enqueue_operation(all_accepted_outbox_operation_input( + second_draft, + [RELAY_PRIMARY_WSS], + )) + .await + .expect("second enqueue"); + let second_claimed = outbox + .claim_next_ready_event("signer", "invalid-plan-sign", 3_000, 2_200) + .await + .expect("second sign claim") + .expect("second sign claim"); + complete_claimed_signing(&outbox, &second_claimed, 2_300).await; + let second_publish_claim = outbox + .claim_next_ready_event("publisher", "invalid-plan-publish", 4_000, 2_300) + .await + .expect("second publish claim") + .expect("second publish claim"); + let mut invalid_claim = second_publish_claim.clone(); + invalid_claim.active_delivery_plan_id = None; + let error = publish_claimed_outbox_event_with_transport( + &outbox, + &store, + &ScriptedTransport::new(Vec::new()), + &invalid_claim, + RadrootsOutboxPublishPolicy::new(3_500), + 2_400, + ) + .await + .expect_err("missing plan rejected"); + assert!(matches!(error, RadrootsRelayTransportError::Transport(_))); + + invalid_claim.active_delivery_plan_id = Some(i64::MAX); + let error = publish_claimed_outbox_event_with_transport( + &outbox, + &store, + &ScriptedTransport::new(Vec::new()), + &invalid_claim, + RadrootsOutboxPublishPolicy::new(3_500), + 2_401, + ) + .await + .expect_err("unknown plan rejected"); + assert!(matches!(error, RadrootsRelayTransportError::Transport(_))); + + let event = outbox + .get_event(receipt.outbox_event_id) + .await + .expect("event") + .expect("event"); + assert_eq!(event.state, RadrootsOutboxEventState::Published); +} + +#[tokio::test] +async fn outbox_transport_facade_requires_signed_claims() { + let signed = signed_post("missing transport signature"); + let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); + let store = RadrootsEventStore::open_memory().await.expect("store"); + let draft = RadrootsEventDraft::new( + "radroots.social.post.v1", + KIND_POST, + signed.created_at(), + signed.tags_as_vec(), + signed.content().to_owned(), + signed.pubkey_str(), + ) + .expect("draft"); + let receipt = outbox + .enqueue_operation(all_accepted_outbox_operation_input( + draft, + [RELAY_PRIMARY_WSS], + )) + .await + .expect("enqueue"); + let claimed = outbox + .claim_next_ready_event("signer", "unsigned-transport", 2_000, 1_000) + .await + .expect("claim") + .expect("claim"); + + let error = publish_claimed_outbox_event_with_transport( + &outbox, + &store, + &ScriptedTransport::new(Vec::new()), + &claimed, + RadrootsOutboxPublishPolicy::new(2_500), + 1_100, + ) + .await + .expect_err("missing signature rejected"); + assert!(matches!( + error, + RadrootsRelayTransportError::MissingSignedOutboxEvent(event_id) + if event_id == receipt.outbox_event_id + )); +} + +#[tokio::test] async fn outbox_publish_fans_out_endpoint_receipts_to_scoped_logical_targets() { let signed = signed_post("scoped duplicate relay"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); @@ -2091,6 +2734,7 @@ async fn outbox_publish_required_targets_fan_out_same_endpoint_scoped_receipts() .expect("draft"); let required = scoped_nostr_target(RELAY_PRIMARY_WSS, "foodshed.west", "West foodshed"); let optional = scoped_nostr_target(RELAY_PRIMARY_WSS, "foodshed.east", "East foodshed"); + let terminal = scoped_nostr_target(RELAY_PRIMARY_WSS, "foodshed.closed", "Closed foodshed"); let receipt = outbox .enqueue_operation(RadrootsOutboxOperationInput::new( "publish_post", @@ -2103,7 +2747,12 @@ async fn outbox_publish_required_targets_fan_out_same_endpoint_scoped_receipts() vec![required.fingerprint.clone()], ) .expect("required target policy"), - vec![required.clone(), optional.clone()], + vec![ + required.clone(), + optional.clone(), + terminal.clone(), + RadrootsTransportTarget::reticulum().expect("reticulum target"), + ], ), 1_000, )) @@ -2120,6 +2769,35 @@ async fn outbox_publish_required_targets_fan_out_same_endpoint_scoped_receipts() .await .expect("claim") .expect("publish claim"); + let optional_record = publish_claim + .delivery_targets + .iter() + .find(|target| target.endpoint_fingerprint == optional.fingerprint) + .expect("optional target"); + outbox + .mark_delivery_target_accepted( + publish_claim.outbox_event_id, + publish_claim.claim_token.as_str(), + optional_record.delivery_target_id, + 2_150, + ) + .await + .expect("optional target accepted"); + let terminal_record = publish_claim + .delivery_targets + .iter() + .find(|target| target.endpoint_fingerprint == terminal.fingerprint) + .expect("terminal target"); + outbox + .mark_delivery_target_failed_terminal( + publish_claim.outbox_event_id, + publish_claim.claim_token.as_str(), + terminal_record.delivery_target_id, + "terminal target", + 2_151, + ) + .await + .expect("terminal target completed"); let adapter = RadrootsMockRelayPublishAdapter::new() .with_outcome(RELAY_PRIMARY_WSS, RadrootsRelayOutcome::accepted()); @@ -2128,7 +2806,7 @@ async fn outbox_publish_required_targets_fan_out_same_endpoint_scoped_receipts() &store, &adapter, &publish_claim, - RadrootsOutboxPublishPolicy::new(2_500), + RadrootsOutboxPublishPolicy::new(2_500).republish_accepted_relays(true), 2_200, ) .await @@ -2158,10 +2836,24 @@ async fn outbox_publish_required_targets_fan_out_same_endpoint_scoped_receipts() .delivery_targets(receipt.outbox_event_id) .await .expect("targets"); - assert_eq!(targets.len(), 2); - assert!(targets.iter().all(|target| { - target.endpoint_uri.as_str() == RELAY_PRIMARY_WSS - && target.status == RadrootsOutboxDeliveryTargetStatus::Accepted + assert_eq!(targets.len(), 4); + assert!( + targets + .iter() + .filter(|target| { target.transport_kind == RadrootsTransportKind::Nostr }) + .filter(|target| target.endpoint_fingerprint != terminal.fingerprint) + .all(|target| { + target.endpoint_uri.as_str() == RELAY_PRIMARY_WSS + && target.status == RadrootsOutboxDeliveryTargetStatus::Accepted + }) + ); + assert!(targets.iter().any(|target| { + target.endpoint_fingerprint == terminal.fingerprint + && target.status == RadrootsOutboxDeliveryTargetStatus::FailedTerminal + })); + assert!(targets.iter().any(|target| { + target.transport_kind == RadrootsTransportKind::Reticulum + && target.status == RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented })); } @@ -2907,6 +3599,21 @@ async fn outbox_publish_rejects_invalid_relay_target_uri_before_adapter_publish( RadrootsRelayTransportError::RelayUrlForbiddenDestination { .. } )); assert!(adapter.captured_raw_events().is_empty()); + + let transport_error = publish_claimed_outbox_event_with_transport( + &outbox, + &store, + &ScriptedTransport::new(Vec::new()), + &publish_claim, + RadrootsOutboxPublishPolicy::new(2_500), + 2_201, + ) + .await + .expect_err("invalid transport relay target"); + assert!(matches!( + transport_error, + RadrootsRelayTransportError::RelayUrlForbiddenDestination { .. } + )); let event = outbox .get_event(receipt.outbox_event_id) .await diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs @@ -1302,23 +1302,14 @@ fn validate_job_status_state( return Err(TransportPublishProtocolError::InvalidJobStatusState); } let required_count = job.delivery_policy.required_target_count(job.target_count); - let ( - satisfied, - retryable_status_count, - terminal_status_count, - has_deferred, - has_deferred_until_implemented, - ) = match &job.delivery_policy { + let (satisfied, retryable_status_count, terminal_status_count, has_deferred) = match &job + .delivery_policy + { TransportPublishDeliveryPolicy::RequiredTargets { targets } => { let required_outcomes = required_policy_outcomes(targets, &job.targets)?; - let satisfied = targets.iter().all(|required| { - required_outcomes.iter().any(|outcome| { - target_outcome_fingerprint(outcome, 0).is_ok_and(|fingerprint| { - fingerprint == *required - && outcome.outcome_kind.counts_toward_accepted_delivery() - }) - }) - }); + let satisfied = required_outcomes + .iter() + .all(|outcome| outcome.outcome_kind.counts_toward_accepted_delivery()); ( satisfied, required_outcomes @@ -1332,54 +1323,35 @@ fn validate_job_status_state( required_outcomes.iter().any(|outcome| { outcome.outcome_kind == TransportPublishOutcomeKind::DeferredUntilImplemented }), - required_outcomes.iter().any(|outcome| { - outcome.outcome_kind == TransportPublishOutcomeKind::DeferredUntilImplemented - }), ) } TransportPublishDeliveryPolicy::Any | TransportPublishDeliveryPolicy::All | TransportPublishDeliveryPolicy::Quorum { .. } => ( - required_count > 0 && acknowledged_count >= required_count, + acknowledged_count >= required_count, retryable_count, terminal_count, job.targets.iter().any(|target| { target.outcome_kind == TransportPublishOutcomeKind::DeferredUntilImplemented }), - job.targets.iter().any(|target| { - target.outcome_kind == TransportPublishOutcomeKind::DeferredUntilImplemented - }), ), }; - match job.status { - TransportPublishJobStatus::DeliverySatisfied if satisfied => Ok(()), - TransportPublishJobStatus::DeliveryUnsatisfiedRetryable - if !satisfied && retryable_status_count > 0 => - { - Ok(()) - } - TransportPublishJobStatus::DeliveryUnsatisfiedTerminal - if !satisfied && retryable_status_count == 0 && terminal_status_count > 0 => - { - Ok(()) - } - TransportPublishJobStatus::DeliveryDeferred - if !satisfied - && terminal_status_count == 0 - && retryable_status_count == 0 - && has_deferred => - { - Ok(()) - } - TransportPublishJobStatus::DeliveryDeferredUntilImplemented - if !satisfied - && terminal_status_count == 0 - && retryable_status_count == 0 - && has_deferred_until_implemented => - { - Ok(()) - } - _ => Err(TransportPublishProtocolError::InvalidJobStatusState), + let status_matches = if satisfied { + job.status == TransportPublishJobStatus::DeliverySatisfied + } else if retryable_status_count > 0 { + job.status == TransportPublishJobStatus::DeliveryUnsatisfiedRetryable + } else if terminal_status_count > 0 { + job.status == TransportPublishJobStatus::DeliveryUnsatisfiedTerminal + } else if has_deferred { + job.status == TransportPublishJobStatus::DeliveryDeferred + || job.status == TransportPublishJobStatus::DeliveryDeferredUntilImplemented + } else { + false + }; + if status_matches { + Ok(()) + } else { + Err(TransportPublishProtocolError::InvalidJobStatusState) } } @@ -2264,6 +2236,22 @@ mod tests { "delivery quorum must be greater than zero", ), ( + TransportPublishProtocolError::EmptyRequiredTargetSet, + "delivery required target set must not be empty", + ), + ( + TransportPublishProtocolError::InvalidRequiredTargetFingerprint { index: 1 }, + "delivery required target 1 fingerprint is invalid", + ), + ( + TransportPublishProtocolError::DuplicateRequiredTargetFingerprint { index: 2 }, + "delivery required target 2 duplicates an earlier fingerprint", + ), + ( + TransportPublishProtocolError::RequiredTargetNotInTargetSet { index: 3 }, + "delivery required target 3 is not in the target set", + ), + ( TransportPublishProtocolError::EmptyPrincipalId, "principal id must not be empty", ), @@ -2272,9 +2260,65 @@ mod tests { "job id must not be empty", ), ( + TransportPublishProtocolError::InvalidJobTargetCount { + expected: 1, + actual: 2, + }, + "job target_count 2 does not match 1 target outcomes", + ), + ( + TransportPublishProtocolError::InvalidJobAcknowledgedCount { + expected: 1, + actual: 2, + }, + "job acknowledged_count 2 does not match 1 target outcomes", + ), + ( + TransportPublishProtocolError::InvalidJobRetryableCount { + expected: 1, + actual: 2, + }, + "job retryable_count 2 does not match 1 target outcomes", + ), + ( + TransportPublishProtocolError::InvalidJobTerminalCount { + expected: 1, + actual: 2, + }, + "job terminal_count 2 does not match 1 target outcomes", + ), + ( + TransportPublishProtocolError::InvalidJobTerminalState, + "job terminal flag does not match status", + ), + ( + TransportPublishProtocolError::InvalidJobDeliverySatisfiedState, + "job delivery_satisfied flag does not match status", + ), + ( + TransportPublishProtocolError::InvalidJobCompletedAt, + "job completed_at_ms does not match status or request time", + ), + ( + TransportPublishProtocolError::InvalidJobStatusState, + "job status does not match target outcomes", + ), + ( TransportPublishProtocolError::InvalidExplicitTargetOutcome { index: 6 }, "transport target outcome 6 does not match explicit target policy", ), + ( + TransportPublishProtocolError::InvalidTargetOutcomeKind { index: 7 }, + "transport target outcome 7 kind is not valid for its transport", + ), + ( + TransportPublishProtocolError::InvalidTargetSource { index: 8 }, + "transport target outcome 8 source does not match transport kind", + ), + ( + TransportPublishProtocolError::InvalidReticulumOutcome { index: 9 }, + "transport target outcome 9 Reticulum must be unavailable or deferred", + ), ]; for (error, message) in cases { @@ -2538,4 +2582,378 @@ mod tests { Err(TransportPublishProtocolError::InvalidQuorum) ); } + + #[test] + fn low_level_target_validation_covers_defensive_mapping_boundaries() { + assert_eq!( + transport_kind_error(RadrootsTransportError::EmptyTransportKind, 1), + TransportPublishProtocolError::EmptyTransportKind { index: 1 } + ); + assert_eq!( + transport_kind_error(RadrootsTransportError::InvalidTransportKind, 2), + TransportPublishProtocolError::InvalidTransportKind { index: 2 } + ); + assert_eq!( + target_fingerprint_error(RadrootsTransportError::EmptyTargetUri, 3), + TransportPublishProtocolError::EmptyEndpointUri { index: 3 } + ); + assert_eq!( + target_fingerprint_error(RadrootsTransportError::InvalidTargetUri, 4), + TransportPublishProtocolError::InvalidEndpointUri { index: 4 } + ); + for (error, expected) in [ + ( + RadrootsTransportError::EmptyTargetScope, + TransportPublishProtocolError::EmptyTargetScope { index: 5 }, + ), + ( + RadrootsTransportError::InvalidTargetScope, + TransportPublishProtocolError::InvalidTargetScope { index: 5 }, + ), + ( + RadrootsTransportError::EmptyTargetLabel, + TransportPublishProtocolError::EmptyTargetLabel { index: 5 }, + ), + ( + RadrootsTransportError::InvalidTargetLabel, + TransportPublishProtocolError::InvalidTargetLabel { index: 5 }, + ), + ( + RadrootsTransportError::InvalidTargetUri, + TransportPublishProtocolError::InvalidEndpointUri { index: 5 }, + ), + ] { + assert_eq!(target_metadata_error(error, 5), expected); + } + + let invalid_fingerprint: RadrootsTransportTargetFingerprint = + serde_json::from_str("\"invalid\"").expect("unchecked serde newtype fixture"); + assert_eq!( + TransportPublishDeliveryPolicy::required_targets(vec![invalid_fingerprint]), + Err(TransportPublishProtocolError::InvalidRequiredTargetFingerprint { index: 0 }) + ); + + assert!( + transport_target_from_parts(RadrootsTransportKind::Local, "local:publish", None, None,) + .is_ok() + ); + assert_eq!( + transport_target_from_parts( + RadrootsTransportKind::Reticulum, + "reticulum:other", + None, + None, + ), + Err(RadrootsTransportError::InvalidTargetUri) + ); + + let valid = nostr_outcome(TransportPublishOutcomeKind::Accepted); + assert!(validate_target_outcome(&valid, 0).is_ok()); + assert!(target_outcome_fingerprint(&valid, 0).is_ok()); + + let mut invalid_target = TransportPublishTarget::nostr("wss://relay.example"); + invalid_target.transport_kind = "Nostr".to_owned(); + assert_eq!( + invalid_target.fingerprint(10), + Err(TransportPublishProtocolError::InvalidTransportKind { index: 10 }) + ); + invalid_target = TransportPublishTarget::nostr("wss://relay.example"); + invalid_target.target_scope = Some(" ".to_owned()); + assert_eq!( + invalid_target.fingerprint(11), + Err(TransportPublishProtocolError::InvalidTargetScope { index: 11 }) + ); + invalid_target = TransportPublishTarget::nostr("wss://relay.example"); + invalid_target.target_label = Some(" ".to_owned()); + assert_eq!( + invalid_target.fingerprint(12), + Err(TransportPublishProtocolError::EmptyTargetLabel { index: 12 }) + ); + + let target = TransportPublishTarget::nostr("wss://relay.example"); + let mut different_kind = valid.clone(); + different_kind.transport_kind = "local".to_owned(); + different_kind.endpoint_uri = "local:publish".to_owned(); + assert!( + !target + .identity_eq(0, &different_kind, 0) + .expect("different transport identity") + ); + + let mut invalid = valid.clone(); + invalid.transport_kind = " ".to_owned(); + assert_eq!( + validate_target_outcome(&invalid, 1), + Err(TransportPublishProtocolError::EmptyTransportKind { index: 1 }) + ); + invalid.transport_kind = "Nostr".to_owned(); + assert_eq!( + validate_target_outcome(&invalid, 2), + Err(TransportPublishProtocolError::InvalidTransportKind { index: 2 }) + ); + assert_eq!( + target_outcome_fingerprint(&invalid, 2), + Err(TransportPublishProtocolError::InvalidTransportKind { index: 2 }) + ); + invalid = valid.clone(); + invalid.endpoint_uri = " ".to_owned(); + assert_eq!( + validate_target_outcome(&invalid, 3), + Err(TransportPublishProtocolError::EmptyEndpointUri { index: 3 }) + ); + invalid = valid.clone(); + invalid.target_scope = Some(" ".to_owned()); + assert_eq!( + validate_target_outcome(&invalid, 4), + Err(TransportPublishProtocolError::InvalidTargetScope { index: 4 }) + ); + assert_eq!( + target_outcome_fingerprint(&invalid, 4), + Err(TransportPublishProtocolError::InvalidTargetScope { index: 4 }) + ); + invalid = valid.clone(); + invalid.target_label = Some(" ".to_owned()); + assert_eq!( + validate_target_outcome(&invalid, 5), + Err(TransportPublishProtocolError::EmptyTargetLabel { index: 5 }) + ); + assert_eq!( + target_outcome_fingerprint(&invalid, 5), + Err(TransportPublishProtocolError::EmptyTargetLabel { index: 5 }) + ); + invalid = valid.clone(); + invalid.endpoint_uri = "not a URI".to_owned(); + assert_eq!( + target_outcome_fingerprint(&invalid, 6), + Err(TransportPublishProtocolError::InvalidEndpointUri { index: 6 }) + ); + + let mut reticulum = + reticulum_outcome(TransportPublishOutcomeKind::DeferredUntilImplemented); + reticulum.endpoint_uri = "reticulum:other".to_owned(); + assert_eq!( + validate_target_outcome(&reticulum, 7), + Err(TransportPublishProtocolError::InvalidReticulumEndpoint { index: 7 }) + ); + reticulum.endpoint_uri = RADROOTS_RETICULUM_ENDPOINT_URI.to_owned(); + reticulum.attempted = true; + assert_eq!( + validate_target_outcome(&reticulum, 8), + Err(TransportPublishProtocolError::InvalidReticulumOutcome { index: 8 }) + ); + let mut wrong_source = valid.clone(); + wrong_source.source = TransportPublishTargetSource::Reticulum; + assert_eq!( + validate_target_outcome(&wrong_source, 9), + Err(TransportPublishProtocolError::InvalidTargetSource { index: 9 }) + ); + + let required = TransportPublishTarget::nostr("wss://required.example") + .fingerprint(0) + .expect("required fingerprint"); + let mut unparseable = nostr_outcome(TransportPublishOutcomeKind::Accepted); + unparseable.endpoint_uri = "not a URI".to_owned(); + assert_eq!( + required_policy_outcomes(&[required], &[unparseable]), + Err(TransportPublishProtocolError::RequiredTargetNotInTargetSet { index: 0 }) + ); + + assert!( + validate_job_target_policy_outcomes( + &TransportPublishTargetPolicy::nostr( + NostrPublishTargetSourcePolicy::ExplicitOnly, + Vec::new(), + ), + &[], + ) + .is_ok() + ); + assert!( + validate_job_target_policy_outcomes( + &TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr("wss://relay.example"), + ]), + &[], + ) + .is_ok() + ); + + let nostr_request = TransportPublishEventRequest { + raw_event_json: raw_event_json(), + target_policy: TransportPublishTargetPolicy::nostr( + NostrPublishTargetSourcePolicy::DaemonDefaultOnly, + Vec::new(), + ), + delivery_policy: TransportPublishDeliveryPolicy::Any, + idempotency_key: None, + timeout_ms: None, + }; + nostr_request + .validate(10) + .expect("valid Nostr policy request"); + + let accepted_without_targets = TransportPublishJobView { + job_id: "accepted-empty".to_owned(), + status: TransportPublishJobStatus::Accepted, + terminal: false, + delivery_satisfied: false, + event_id: "0".repeat(64), + pubkey: "1".repeat(64), + event_kind: 30_402, + target_policy: TransportPublishTargetPolicy::nostr( + NostrPublishTargetSourcePolicy::DaemonDefaultOnly, + Vec::new(), + ), + delivery_policy: TransportPublishDeliveryPolicy::Any, + target_count: 0, + acknowledged_count: 0, + retryable_count: 0, + terminal_count: 0, + requested_at_ms: 1, + completed_at_ms: None, + last_error: None, + targets: Vec::new(), + }; + accepted_without_targets + .validate() + .expect("accepted job may await target resolution"); + } + + #[test] + fn job_count_and_status_guards_reject_every_inconsistent_shape() { + let mut retryable_count_mismatch = job_from_targets( + TransportPublishJobStatus::DeliveryUnsatisfiedRetryable, + TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget::nostr( + "wss://relay.example.com", + )]), + vec![nostr_outcome(TransportPublishOutcomeKind::Timeout)], + ); + retryable_count_mismatch.retryable_count = 0; + assert_eq!( + retryable_count_mismatch.validate(), + Err(TransportPublishProtocolError::InvalidJobRetryableCount { + expected: 1, + actual: 0, + }) + ); + + let mut terminal_count_mismatch = job_from_targets( + TransportPublishJobStatus::DeliveryUnsatisfiedTerminal, + TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget::nostr( + "wss://relay.example.com", + )]), + vec![nostr_outcome(TransportPublishOutcomeKind::TargetRejected)], + ); + terminal_count_mismatch.terminal_count = 0; + assert_eq!( + terminal_count_mismatch.validate(), + Err(TransportPublishProtocolError::InvalidJobTerminalCount { + expected: 1, + actual: 0, + }) + ); + + let rejected = rejected_job(); + assert!(validate_job_status_state(&rejected, 0, 0, 0).is_ok()); + for (target_count, targets, acknowledged, retryable, terminal) in [ + (1, Vec::new(), 0, 0, 0), + ( + 0, + vec![nostr_outcome(TransportPublishOutcomeKind::Accepted)], + 0, + 0, + 0, + ), + (0, Vec::new(), 1, 0, 0), + (0, Vec::new(), 0, 1, 0), + (0, Vec::new(), 0, 0, 1), + ] { + let mut invalid = rejected.clone(); + invalid.target_count = target_count; + invalid.targets = targets; + assert_eq!( + validate_job_status_state(&invalid, acknowledged, retryable, terminal), + Err(TransportPublishProtocolError::InvalidJobStatusState) + ); + } + + let mut empty_terminal = rejected.clone(); + empty_terminal.status = TransportPublishJobStatus::DeliveryUnsatisfiedTerminal; + assert_eq!( + validate_job_status_state(&empty_terminal, 0, 0, 0), + Err(TransportPublishProtocolError::InvalidJobStatusState) + ); + let mut publishing = rejected.clone(); + publishing.status = TransportPublishJobStatus::Publishing; + assert!(validate_job_status_state(&publishing, 0, 0, 0).is_ok()); + + let accepted = accepted_job(); + assert!(validate_job_status_state(&accepted, 1, 0, 0).is_ok()); + assert_eq!( + validate_job_status_state(&accepted, 0, 1, 0), + Err(TransportPublishProtocolError::InvalidJobStatusState) + ); + let retryable = job_from_targets( + TransportPublishJobStatus::DeliveryUnsatisfiedRetryable, + TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget::nostr( + "wss://relay.example.com", + )]), + vec![nostr_outcome(TransportPublishOutcomeKind::Timeout)], + ); + assert!(validate_job_status_state(&retryable, 0, 1, 0).is_ok()); + assert_eq!( + validate_job_status_state(&retryable, 1, 1, 0), + Err(TransportPublishProtocolError::InvalidJobStatusState) + ); + assert_eq!( + validate_job_status_state(&retryable, 0, 0, 0), + Err(TransportPublishProtocolError::InvalidJobStatusState) + ); + let terminal = job_from_targets( + TransportPublishJobStatus::DeliveryUnsatisfiedTerminal, + TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget::nostr( + "wss://relay.example.com", + )]), + vec![nostr_outcome(TransportPublishOutcomeKind::TargetRejected)], + ); + assert!(validate_job_status_state(&terminal, 0, 0, 1).is_ok()); + assert_eq!( + validate_job_status_state(&terminal, 0, 1, 1), + Err(TransportPublishProtocolError::InvalidJobStatusState) + ); + assert_eq!( + validate_job_status_state(&terminal, 0, 0, 0), + Err(TransportPublishProtocolError::InvalidJobStatusState) + ); + + for status in [ + TransportPublishJobStatus::DeliveryDeferred, + TransportPublishJobStatus::DeliveryDeferredUntilImplemented, + ] { + let deferred = job_from_targets( + status, + TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::reticulum( + TransportPublishReticulumBehavior::DeferDeliveryPlans, + ), + ]), + vec![reticulum_outcome( + TransportPublishOutcomeKind::DeferredUntilImplemented, + )], + ); + assert!(validate_job_status_state(&deferred, 0, 0, 0).is_ok()); + assert_eq!( + validate_job_status_state(&deferred, 1, 0, 0), + Err(TransportPublishProtocolError::InvalidJobStatusState) + ); + assert_eq!( + validate_job_status_state(&deferred, 0, 1, 0), + Err(TransportPublishProtocolError::InvalidJobStatusState) + ); + assert_eq!( + validate_job_status_state(&deferred, 0, 0, 1), + Err(TransportPublishProtocolError::InvalidJobStatusState) + ); + } + } } diff --git a/crates/transport_reticulum/src/lib.rs b/crates/transport_reticulum/src/lib.rs @@ -1,5 +1,6 @@ #![no_std] #![forbid(unsafe_code)] +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] extern crate alloc; @@ -460,3 +461,227 @@ fn reticulum_outcome(behavior: RadrootsReticulumBehavior) -> RadrootsTransportOu ); outcome } + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use alloc::format; + use alloc::string::ToString; + use alloc::vec; + use futures::executor::block_on; + use radroots_transport::{ + RadrootsTransportPayload, RadrootsTransportSatisfactionPolicy, RadrootsTransportTargetSet, + }; + + fn reticulum_target() -> RadrootsTransportTarget { + RadrootsTransportTarget::reticulum().expect("Reticulum target") + } + + fn delivery_request(targets: Vec<RadrootsTransportTarget>) -> RadrootsTransportDeliveryRequest { + RadrootsTransportDeliveryRequest::new( + "delivery", + RadrootsTransportPayload::mesh_frame_cbor("message", [1_u8, 2, 3]) + .expect("mesh payload"), + RadrootsTransportTargetSet::new(targets).expect("target set"), + RadrootsTransportSatisfactionPolicy::any_accepted(), + ) + } + + #[test] + #[allow(clippy::unnecessary_to_owned)] + fn endpoint_profile_and_fetch_models_cover_owned_and_borrowed_boundaries() { + let endpoint = + RadrootsReticulumEndpoint::parse(RADROOTS_RETICULUM_ENDPOINT_URI).expect("endpoint"); + assert_eq!(endpoint.as_str(), RADROOTS_RETICULUM_ENDPOINT_URI); + assert_eq!(format!("{endpoint}"), RADROOTS_RETICULUM_ENDPOINT_URI); + assert_eq!( + endpoint.clone().into_string(), + RADROOTS_RETICULUM_ENDPOINT_URI + ); + assert_eq!( + RadrootsReticulumEndpoint::default(), + RadrootsReticulumEndpoint::parse(RADROOTS_RETICULUM_ENDPOINT_URI.to_string()) + .expect("owned endpoint") + ); + assert!(RadrootsReticulumEndpoint::parse("reticulum:other").is_err()); + + for invalid in [ + "", + " reticulum-agent:local", + "reticulum-agent:local ", + "reticulum-agent:\tlocal", + "other:local", + RETICULUM_AGENT_ENDPOINT_PREFIX, + ] { + assert!(RadrootsReticulumAgentEndpoint::parse(invalid).is_err()); + } + let agent = + RadrootsReticulumAgentEndpoint::parse("reticulum-agent:local").expect("agent endpoint"); + assert_eq!(agent.as_str(), "reticulum-agent:local"); + assert_eq!(format!("{agent}"), "reticulum-agent:local"); + assert_eq!(agent.clone().into_string(), "reticulum-agent:local"); + assert_eq!( + RadrootsReticulumAgentEndpoint::parse("reticulum-agent:owned".to_string()) + .expect("owned agent") + .as_str(), + "reticulum-agent:owned" + ); + + let scope = RadrootsTransportMeshScopeId::parse("farm.mesh").expect("scope"); + for invalid in ["", " ", "profile id"] { + assert!( + RadrootsReticulumProfile::new( + invalid, + endpoint.clone(), + scope.clone(), + None, + RadrootsReticulumBehavior::RejectDeliveryAttempts, + ) + .is_err() + ); + } + let profile = RadrootsReticulumProfile::new( + "transport.reticulum.farm".to_string(), + endpoint.clone(), + scope.clone(), + Some(agent.clone()), + RadrootsReticulumBehavior::RejectDeliveryAttempts, + ) + .expect("profile") + .with_behavior(RadrootsReticulumBehavior::DeferDeliveryPlans) + .with_agent_endpoint(agent.clone()); + assert_eq!(profile.profile_id(), "transport.reticulum.farm"); + assert_eq!(profile.endpoint(), &endpoint); + assert_eq!(profile.scope(), &scope); + assert_eq!(profile.agent_endpoint(), Some(&agent)); + assert_eq!( + profile.behavior(), + RadrootsReticulumBehavior::DeferDeliveryPlans + ); + assert_eq!( + profile.destination(), + &profile.capability_report().destination + ); + assert_eq!(profile.status().behavior, profile.behavior()); + + let default_profile = RadrootsReticulumProfile::deferred_until_implemented(); + assert_eq!(default_profile, RadrootsReticulumProfile::default()); + assert!(default_profile.agent_endpoint().is_none()); + + assert!(RadrootsReticulumFetchRequest::new("invalid", 0).is_err()); + let fetch = + RadrootsReticulumFetchRequest::new("fetch".to_string(), 1).expect("fetch request"); + assert_eq!(fetch.request_id, "fetch"); + } + + #[test] + fn transport_facades_and_private_guards_cover_all_contract_outcomes() { + let rejecting = RadrootsReticulumTransport::default(); + assert_eq!( + rejecting.profile().behavior(), + RadrootsReticulumBehavior::RejectDeliveryAttempts + ); + assert_eq!(rejecting.status(), rejecting.profile().status()); + let receipt = rejecting + .deliver(delivery_request(vec![reticulum_target()])) + .expect("direct delivery"); + assert_eq!(receipt.target_receipts.len(), 1); + assert_eq!( + rejecting + .fetch(RadrootsReticulumFetchRequest::new("direct-fetch", 1).expect("fetch")) + .expect("direct fetch") + .observed_event_count, + 0 + ); + assert!( + rejecting + .fetch(RadrootsReticulumFetchRequest { + request_id: "invalid-fetch".to_owned(), + max_events: 0, + }) + .is_err() + ); + + assert_eq!( + RadrootsTransport::transport_kind(&rejecting), + RadrootsTransportKind::Reticulum + ); + assert!(block_on(RadrootsTransport::status(&rejecting)).is_ok()); + assert!( + block_on(RadrootsTransport::deliver( + &rejecting, + delivery_request(vec![reticulum_target()]), + )) + .is_ok() + ); + let core_fetch = RadrootsTransportFetchRequest::new( + "core-fetch", + RadrootsTransportTargetSet::new(vec![reticulum_target()]).expect("target set"), + ); + assert!(block_on(RadrootsTransport::fetch(&rejecting, core_fetch)).is_ok()); + + let deferring = RadrootsReticulumTransport::new( + RadrootsReticulumProfile::default() + .with_behavior(RadrootsReticulumBehavior::DeferDeliveryPlans), + ); + assert_eq!( + deferring + .deliver(delivery_request(vec![reticulum_target()])) + .expect("deferred delivery") + .target_receipts[0] + .outcome + .kind, + RadrootsTransportOutcomeKind::DeferredUntilImplemented + ); + + assert_eq!( + reticulum_error_to_transport_error(RadrootsReticulumError::InvalidEndpoint), + RadrootsTransportError::InvalidTargetUri + ); + assert_eq!( + reticulum_error_to_transport_error(RadrootsReticulumError::NonReticulumTarget), + RadrootsTransportError::InvalidTargetUri + ); + for error in [ + RadrootsReticulumError::InvalidAgentEndpoint, + RadrootsReticulumError::InvalidProfileId, + RadrootsReticulumError::InvalidFetchLimit, + ] { + assert_eq!( + reticulum_error_to_transport_error(error), + RadrootsTransportError::InvalidTransportKind + ); + } + + assert!(ensure_reticulum_targets(&[]).is_ok()); + let mut wrong_kind = reticulum_target(); + wrong_kind.kind = RadrootsTransportKind::Local; + assert_eq!( + ensure_reticulum_targets(&[wrong_kind]), + Err(RadrootsReticulumError::NonReticulumTarget) + ); + let mut wrong_uri = reticulum_target(); + wrong_uri.uri = radroots_transport::RadrootsTransportTargetUri::parse("reticulum:other") + .expect("syntactically valid wrong URI"); + assert_eq!( + ensure_reticulum_targets(&[wrong_uri]), + Err(RadrootsReticulumError::InvalidEndpoint) + ); + let mut missing_scope = reticulum_target(); + missing_scope.scope = None; + assert_eq!( + ensure_reticulum_targets(&[missing_scope]), + Err(RadrootsReticulumError::InvalidEndpoint) + ); + + for behavior in [ + RadrootsReticulumBehavior::RejectDeliveryAttempts, + RadrootsReticulumBehavior::DeferDeliveryPlans, + ] { + let outcome = reticulum_outcome(behavior); + assert!(outcome.code.is_some()); + assert!(outcome.message.is_some()); + } + } +} diff --git a/rust-toolchain-coverage.toml b/rust-toolchain-coverage.toml @@ -1,10 +1,4 @@ [toolchain] channel = "nightly-2026-07-15" -components = [ - "clippy", - "rust-analyzer", - "rust-src", - "rustfmt", - "llvm-tools-preview", -] -targets = ["wasm32-unknown-unknown"] +components = ["llvm-tools-preview"] +profile = "minimal"