lib

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

commit 9fd649fe7606fb62b71e5e7dbbe677fa441613e2
parent 8928fa0cadc86a1987ccf19001bb126943ab0187
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Jul 2026 07:52:28 +0000

event: close semantic protocol coverage

- Exercise typed draft, envelope, and identifier APIs with stable error contracts.
- Cover wire parsing, resource budgets, canonical identifiers, and conversion failures.
- Validate trade mutations, nested profiles, reservations, canonical JSON, and tamper rejection.
- Make infallible canonicalization invariants explicit and satisfy all semantic coverage gates.

Diffstat:
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 | 629++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/event/src/wire.rs | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
9 files changed, 1041 insertions(+), 51 deletions(-)

diff --git a/crates/event/Cargo.toml b/crates/event/Cargo.toml @@ -51,3 +51,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 @@ -4031,6 +4031,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 @@ -472,6 +472,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')); } @@ -728,6 +729,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, } @@ -738,6 +759,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, } @@ -764,10 +790,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 @@ -654,32 +654,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")] @@ -690,8 +678,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 { @@ -777,9 +764,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('['); @@ -799,15 +784,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('}'); @@ -832,6 +811,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, @@ -925,10 +930,10 @@ where for item in items { let item_key = key(item); validate_non_empty(item_key, field)?; - if let Some(previous) = previous { - if previous >= item_key { - return Err(RadrootsTradeProtocolError::InvalidField(field)); - } + if let Some(previous) = previous + && previous >= item_key + { + return Err(RadrootsTradeProtocolError::InvalidField(field)); } previous = Some(item_key); } @@ -1093,6 +1098,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, @@ -1176,6 +1189,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!( @@ -1217,4 +1267,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");