lib

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

commit db0a8bef6b95befe80ac61d39179473077076fff
parent 79a1e89995f8c397002f64d1d706714019211d1a
Author: triesap <tyson@radroots.org>
Date:   Mon,  6 Jul 2026 01:29:34 +0000

release: harden knowledge final gates

Diffstat:
Mcontracts/conformance/vectors/knowledge/manifest_and_decode.v1.json | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/conformance/vectors/knowledge/regenproto_compatibility.v1.json | 12++++++++++++
Mcrates/core/Cargo.toml | 1+
Mcrates/events/Cargo.toml | 1+
Mcrates/events/src/contract.rs | 469+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/events/src/draft.rs | 14++++++++++++--
Mcrates/events/src/event_head.rs | 47+++++++++++++++++++++++++++--------------------
Mcrates/events/src/ids.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events/src/kinds.rs | 3+++
Mcrates/events/src/location.rs | 38++++++++++++++++++++++++++++++++++++++
Mcrates/events/src/trade_validation.rs | 25+++++++++++++++++++++++++
Mcrates/events_codec/Cargo.toml | 1+
Mcrates/events_codec/src/farm/mod.rs | 168++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/events_codec/tests/codec_error_job.rs | 9+++++++++
Acrates/events_codec/tests/coverage_edges.rs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events_codec/tests/knowledge_fixtures.rs | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/events_codec/tests/knowledge_manifest.rs | 1+
Mcrates/events_codec/tests/listing.rs | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events_indexed/Cargo.toml | 1+
Mcrates/geocoder/Cargo.toml | 1+
Mcrates/geocoder/src/asset.rs | 22++++++++++++++++++++--
Mcrates/geocoder/tests/geocoder.rs | 187++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/identity/Cargo.toml | 1+
Mcrates/local_events/Cargo.toml | 1+
Mcrates/log/Cargo.toml | 1+
Mcrates/nostr/Cargo.toml | 1+
Mcrates/nostr_accounts/Cargo.toml | 1+
Mcrates/nostr_connect/Cargo.toml | 4++++
Mcrates/nostr_connect/src/client.rs | 22++++++++++++++++------
Mcrates/nostr_connect/src/lib.rs | 1+
Mcrates/nostr_connect/tests/client.rs | 22++++++++++++++++++++++
Mcrates/nostr_connect/tests/coverage.rs | 16++++++++++++++++
Mcrates/nostr_signer/Cargo.toml | 1+
Mcrates/outbox/src/store.rs | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/protected_store/Cargo.toml | 1+
Mcrates/publish_proxy_protocol/src/lib.rs | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/relay_transport/src/fetch.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/relay_transport/src/publish.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/relay_transport/src/relay.rs | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/relay_transport/tests/transport.rs | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/replica_db/Cargo.toml | 1+
Mcrates/replica_db_schema/Cargo.toml | 1+
Mcrates/replica_sync/Cargo.toml | 1+
Mcrates/runtime/Cargo.toml | 1+
Mcrates/runtime_distribution/Cargo.toml | 1+
Mcrates/runtime_manager/Cargo.toml | 1+
Mcrates/runtime_paths/Cargo.toml | 1+
Mcrates/secret_vault/Cargo.toml | 1+
Mcrates/secret_vault/src/selection.rs | 23+++++++++++++++++++++++
Mcrates/sql_core/Cargo.toml | 1+
Mcrates/test_fixtures/src/knowledge.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/trade/Cargo.toml | 1+
Mcrates/trade/src/dvm.rs | 560++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/trade/src/identity.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/trade/src/listing/codec.rs | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/trade/src/listing/mod.rs | 38++++++++++++++++++++++++++++++++------
Mcrates/trade/src/listing/validation.rs | 18++++++++++++------
Mcrates/trade/src/order.rs | 345++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/trade/src/validation_receipt.rs | 151+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/trade/src/workflow.rs | 256+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtools/xtask/src/main.rs | 1+
61 files changed, 3785 insertions(+), 157 deletions(-)

diff --git a/contracts/conformance/vectors/knowledge/manifest_and_decode.v1.json b/contracts/conformance/vectors/knowledge/manifest_and_decode.v1.json @@ -56,6 +56,100 @@ "error_class": "decode_error", "stage": "contract_validation" } + }, + { + "id": "knowledge_nip54_wiki_article_fork_defer_valid_004", + "kind": "knowledge.nip54.wiki_article_tags.valid", + "input": { + "fixture": "wiki_article_valid" + }, + "expected": { + "fork_address_tag": [ + "a", + "30818:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:soil-health", + "wss://relay.example.com", + "fork" + ], + "fork_event_tag": [ + "e", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "wss://relay.example.com", + "fork" + ], + "defer_address_tag": [ + "a", + "30818:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:soil-health-v2", + "wss://relay.example.com", + "defer" + ], + "defer_event_tag": [ + "e", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "wss://relay.example.com", + "defer" + ] + } + }, + { + "id": "knowledge_nip54_redirect_valid_005", + "kind": "knowledge.nip54.wiki_redirect_tags.valid", + "input": { + "fixture": "wiki_redirect_valid" + }, + "expected": { + "target_tag": [ + "a", + "30818:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:soil-health", + "wss://relay.example.com" + ] + } + }, + { + "id": "knowledge_nip54_merge_request_valid_006", + "kind": "knowledge.nip54.wiki_merge_request.valid", + "input": { + "fixture": "wiki_merge_request_valid" + }, + "expected": { + "content": "Merge synthetic soil article updates", + "target_tag": [ + "a", + "30818:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:soil-health", + "wss://relay.example.com" + ], + "destination_tag": [ + "p", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "base_event_tag": [ + "e", + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "" + ], + "source_event_tag": [ + "e", + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "", + "source" + ] + } + }, + { + "id": "knowledge_nip54_merge_request_without_base_valid_007", + "kind": "knowledge.nip54.wiki_merge_request.valid", + "input": { + "fixture": "wiki_merge_request_without_base_valid" + }, + "expected": { + "content": "Merge synthetic soil article updates", + "base_version_event_id": null, + "source_event_tag": [ + "e", + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "", + "source" + ] + } } ] } diff --git a/contracts/conformance/vectors/knowledge/regenproto_compatibility.v1.json b/contracts/conformance/vectors/knowledge/regenproto_compatibility.v1.json @@ -56,6 +56,18 @@ "contract_id": "radroots.wiki.article.v1", "operation": "build_validate_decode_expose" } + }, + { + "id": "knowledge_claim_sdk_builder_downstream_valid_006", + "kind": "knowledge.sdk_builder_event.valid", + "input": { + "sdk": "radroots_sdk", + "builder": "RadrootsKnowledgeClaimBuilder" + }, + "expected": { + "contract_id": "radroots.knowledge.claim.v1", + "operation": "build_event_validate_decode_expose" + } } ] } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Core value model for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_core" readme = "README" [features] diff --git a/crates/events/Cargo.toml b/crates/events/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Domain event model for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_events" readme = "README" [features] diff --git a/crates/events/src/contract.rs b/crates/events/src/contract.rs @@ -3154,8 +3154,10 @@ pub fn identify_event_contract( pub fn validate_event_contract( event: &RadrootsNostrEvent, ) -> Result<&'static RadrootsEventContract, RadrootsContractValidationError> { - let contract = identify_event_contract(event.kind, &event.tags, &event.content) - .map_err(|error| RadrootsContractValidationError::ContractMatch { error })?; + let contract = match identify_event_contract(event.kind, &event.tags, &event.content) { + Ok(contract) => contract, + Err(error) => return Err(RadrootsContractValidationError::ContractMatch { error }), + }; validate_event_contract_shape(event, contract.id)?; Ok(contract) } @@ -3502,6 +3504,87 @@ mod tests { ), ]; + static REQUIRED_MANY_TEST_TAGS: &[RadrootsTagContract] = &[tag( + "test_many", + RadrootsTagCardinality::RequiredMany, + RadrootsTagSemantic::Topic, + RadrootsTagValueType::Text, + false, + )]; + + static OPTIONAL_ONE_TEST_TAGS: &[RadrootsTagContract] = &[tag( + "test_optional", + RadrootsTagCardinality::OptionalOne, + RadrootsTagSemantic::Topic, + RadrootsTagValueType::Text, + false, + )]; + + static DUPLICATE_REQUIRED_TEST_TAGS: &[RadrootsTagContract] = &[ + tag( + "test_required", + RadrootsTagCardinality::RequiredOne, + RadrootsTagSemantic::Topic, + RadrootsTagValueType::Text, + false, + ), + tag( + "test_required", + RadrootsTagCardinality::RequiredOne, + RadrootsTagSemantic::Category, + RadrootsTagValueType::Text, + false, + ), + ]; + + static DUPLICATE_OPTIONAL_TEST_TAGS: &[RadrootsTagContract] = &[ + tag( + "test_optional", + RadrootsTagCardinality::OptionalOne, + RadrootsTagSemantic::Topic, + RadrootsTagValueType::Text, + false, + ), + tag( + "test_optional", + RadrootsTagCardinality::OptionalOne, + RadrootsTagSemantic::Category, + RadrootsTagValueType::Text, + false, + ), + ]; + + fn synthetic_event_contract( + id: &'static str, + tags: &'static [RadrootsTagContract], + ) -> RadrootsEventContract { + RadrootsEventContract { + id, + kind: KIND_POST, + name: "Test", + payload_type: "Test", + class: RadrootsEventClass::Regular, + stability: RadrootsEventStability::Experimental, + privacy: RadrootsEventPrivacy::Public, + author_role: RadrootsActorRole::Any, + content_schema: RadrootsContentSchema::PlainText, + discriminator: RadrootsEventDiscriminator::KindOnly, + tags, + reducers: SOCIAL_REDUCERS, + } + } + + fn synthetic_kind_contract(kind: u32) -> RadrootsKindContract { + RadrootsKindContract { + kind, + canonical_constant: "KIND_TEST", + name: "Test", + class: RadrootsEventClass::Regular, + standard: RadrootsNostrStandard::Radroots, + accepted_event_contracts: &[], + } + } + fn unsigned_event(kind: u32, tags: Vec<Vec<&str>>, content: &str) -> RadrootsNostrEvent { RadrootsNostrEvent { id: "0".repeat(64), @@ -3652,6 +3735,99 @@ mod tests { } #[test] + fn contract_family_helpers_cover_prefixes_and_kind_branches() { + for (id, family) in [ + ( + "radroots.account.test.v1", + Some(RadrootsContractFamily::Account), + ), + ( + "radroots.application.test.v1", + Some(RadrootsContractFamily::Application), + ), + ( + "radroots.calendar.test.v1", + Some(RadrootsContractFamily::Calendar), + ), + ("radroots.farm.test.v1", Some(RadrootsContractFamily::Farm)), + ( + "radroots.group.test.v1", + Some(RadrootsContractFamily::Group), + ), + ("radroots.http.test.v1", Some(RadrootsContractFamily::Http)), + ("radroots.job.test.v1", Some(RadrootsContractFamily::Job)), + ( + "radroots.knowledge.test.v1", + Some(RadrootsContractFamily::Knowledge), + ), + ( + "radroots.wiki.test.v1", + Some(RadrootsContractFamily::Knowledge), + ), + ("radroots.list.test.v1", Some(RadrootsContractFamily::List)), + ( + "radroots.list_set.test.v1", + Some(RadrootsContractFamily::List), + ), + ( + "radroots.listing.test.v1", + Some(RadrootsContractFamily::Market), + ), + ( + "radroots.message.test.v1", + Some(RadrootsContractFamily::Message), + ), + ( + "radroots.profile.test.v1", + Some(RadrootsContractFamily::Profile), + ), + ( + "radroots.relay.test.v1", + Some(RadrootsContractFamily::Relay), + ), + ( + "radroots.trade.test.v1", + Some(RadrootsContractFamily::Trade), + ), + ( + "radroots.order.test.v1", + Some(RadrootsContractFamily::Trade), + ), + ("radroots.test.unknown.v1", None), + ] { + assert_eq!(contract_family_for_id(id), family, "{id}"); + } + + for (kind, family) in [ + (KIND_PROFILE, RadrootsContractFamily::Profile), + (KIND_MESSAGE, RadrootsContractFamily::Message), + (KIND_POST, RadrootsContractFamily::Social), + (KIND_RELAY_AUTH, RadrootsContractFamily::Relay), + (KIND_GROUP_ROLES, RadrootsContractFamily::Group), + (KIND_LIST_SET_GENERIC, RadrootsContractFamily::List), + (KIND_CALENDAR_EVENT_RSVP, RadrootsContractFamily::Calendar), + (KIND_FARM_CRDT_CHANGE, RadrootsContractFamily::Farm), + (KIND_LISTING, RadrootsContractFamily::Market), + (KIND_ORDER_CANCELLATION, RadrootsContractFamily::Trade), + (KIND_KNOWLEDGE_CLAIM, RadrootsContractFamily::Knowledge), + (KIND_JOB_FEEDBACK, RadrootsContractFamily::Job), + (KIND_JOB_REQUEST_MIN, RadrootsContractFamily::Job), + (KIND_JOB_RESULT_MIN, RadrootsContractFamily::Job), + ] { + assert_eq!( + kind_contract_family(&synthetic_kind_contract(kind)), + Some(family), + "{kind}" + ); + } + + assert_eq!( + kind_contract_family(&synthetic_kind_contract(999_999)), + None + ); + } + + #[test] fn exposes_knowledge_contracts() { let wiki_article = event_contract("radroots.wiki.article.v1").expect("wiki article"); assert_eq!(wiki_article.kind, KIND_WIKI_ARTICLE); @@ -3699,10 +3875,31 @@ mod tests { event_contract_family(contract), Some(RadrootsContractFamily::Knowledge) ); - assert!(contract.tags.iter().any(|tag| tag.name == "contract" - && tag.semantic == RadrootsTagSemantic::Contract - && tag.value_type == RadrootsTagValueType::ContractId)); + let contract_tag = contract + .tags + .iter() + .find(|tag| tag.name == "contract") + .expect("contract tag"); + assert_eq!(contract_tag.semantic, RadrootsTagSemantic::Contract); + assert_eq!(contract_tag.value_type, RadrootsTagValueType::ContractId); + } + } + + #[test] + fn custom_knowledge_schema_lookup_covers_registered_ids() { + for id in [ + "radroots.knowledge.source.v1", + "radroots.knowledge.evidence_bounty.v1", + "radroots.knowledge.claim.v1", + "radroots.knowledge.relation.v1", + "radroots.knowledge.review.v1", + "radroots.knowledge.field_report.v1", + "radroots.knowledge.change_proposal.v1", + "radroots.knowledge.contribution_attestation.v1", + ] { + assert_eq!(custom_knowledge_schema(id), Some(id), "{id}"); } + assert_eq!(custom_knowledge_schema("radroots.wiki.article.v1"), None); } #[test] @@ -3730,6 +3927,12 @@ mod tests { identify_event_contract(999_999, &[], "{}"), Err(RadrootsContractMatchError::UnsupportedKind(999_999)) ); + assert_eq!( + validate_event_contract(&unsigned_event(999_999, Vec::new(), "{}")), + Err(RadrootsContractValidationError::ContractMatch { + error: RadrootsContractMatchError::UnsupportedKind(999_999), + }) + ); let tags = vec![vec!["d".to_owned(), "unknown".to_owned()]]; assert_eq!( @@ -3880,6 +4083,176 @@ mod tests { } #[test] + fn validate_event_contract_shape_reports_registry_kind_and_content_errors() { + let event = unsigned_event(KIND_POST, Vec::new(), "hello"); + assert_eq!( + validate_event_contract_shape(&event, "missing.contract.v1"), + Err(RadrootsContractValidationError::UnknownContract { + contract_id: "missing.contract.v1".to_owned(), + }) + ); + assert_eq!( + validate_event_contract_shape(&event, "radroots.profile.metadata.v1"), + Err(RadrootsContractValidationError::KindMismatch { + expected: KIND_PROFILE, + actual: KIND_POST, + }) + ); + + let invalid_json = unsigned_event( + KIND_KNOWLEDGE_CLAIM, + vec![vec!["contract", "radroots.knowledge.claim.v1"]], + "not-json", + ); + assert_eq!( + validate_event_contract_shape(&invalid_json, "radroots.knowledge.claim.v1"), + Err(RadrootsContractValidationError::InvalidJsonContent { + contract_id: "radroots.knowledge.claim.v1", + }) + ); + + assert_eq!( + validate_event_contract_shape( + &unsigned_event(KIND_POST, Vec::new(), "plain text"), + "radroots.social.post.v1", + ), + Ok(()) + ); + } + + #[test] + fn validate_contract_tags_reports_cardinality_errors() { + let missing_required_one = unsigned_event( + KIND_KNOWLEDGE_CLAIM, + Vec::new(), + r#"{"schema":"radroots.knowledge.claim.v1","schema_version":1}"#, + ); + assert_eq!( + validate_event_contract_shape(&missing_required_one, "radroots.knowledge.claim.v1"), + Err(RadrootsContractValidationError::MissingTag { + contract_id: "radroots.knowledge.claim.v1", + name: "contract", + }) + ); + + let duplicate_required_one = unsigned_event( + KIND_KNOWLEDGE_CLAIM, + vec![ + vec!["contract", "radroots.knowledge.claim.v1"], + vec!["contract", "radroots.knowledge.claim.v1"], + ], + r#"{"schema":"radroots.knowledge.claim.v1","schema_version":1}"#, + ); + assert_eq!( + validate_event_contract_shape(&duplicate_required_one, "radroots.knowledge.claim.v1"), + Err(RadrootsContractValidationError::TagCardinalityMismatch { + contract_id: "radroots.knowledge.claim.v1", + name: "contract", + }) + ); + + let required_many = + synthetic_event_contract("radroots.test.required_many.v1", REQUIRED_MANY_TEST_TAGS); + assert_eq!( + validate_contract_tags(&unsigned_event(KIND_POST, Vec::new(), ""), &required_many), + Err(RadrootsContractValidationError::MissingTag { + contract_id: "radroots.test.required_many.v1", + name: "test_many", + }) + ); + assert_eq!( + validate_contract_tags( + &unsigned_event(KIND_POST, vec![vec!["test_many", "one"]], ""), + &required_many, + ), + Ok(()) + ); + + let optional_one = + synthetic_event_contract("radroots.test.optional_one.v1", OPTIONAL_ONE_TEST_TAGS); + assert_eq!( + validate_contract_tags(&unsigned_event(KIND_POST, Vec::new(), ""), &optional_one), + Ok(()) + ); + assert_eq!( + validate_contract_tags( + &unsigned_event( + KIND_POST, + vec![vec!["test_optional", "one"], vec!["test_optional", "two"],], + "", + ), + &optional_one, + ), + Err(RadrootsContractValidationError::TagCardinalityMismatch { + contract_id: "radroots.test.optional_one.v1", + name: "test_optional", + }) + ); + + let duplicate_required = synthetic_event_contract( + "radroots.test.duplicate_required.v1", + DUPLICATE_REQUIRED_TEST_TAGS, + ); + assert_eq!( + validate_contract_tags( + &unsigned_event( + KIND_POST, + vec![vec!["test_required", "one"], vec!["test_required", "two"],], + "", + ), + &duplicate_required, + ), + Ok(()) + ); + + let duplicate_optional = synthetic_event_contract( + "radroots.test.duplicate_optional.v1", + DUPLICATE_OPTIONAL_TEST_TAGS, + ); + assert_eq!( + validate_contract_tags( + &unsigned_event( + KIND_POST, + vec![vec!["test_optional", "one"], vec!["test_optional", "two"],], + "", + ), + &duplicate_optional, + ), + Ok(()) + ); + } + + #[test] + fn validate_custom_knowledge_contract_rejects_missing_schema_and_bad_version() { + let missing_schema = unsigned_event( + KIND_KNOWLEDGE_CLAIM, + vec![vec!["contract", "radroots.knowledge.claim.v1"]], + r#"{"schema_version":1}"#, + ); + assert_eq!( + validate_event_contract_shape(&missing_schema, "radroots.knowledge.claim.v1"), + Err(RadrootsContractValidationError::MissingContentField { + contract_id: "radroots.knowledge.claim.v1", + field: "schema", + }) + ); + + let bad_version = unsigned_event( + KIND_KNOWLEDGE_CLAIM, + vec![vec!["contract", "radroots.knowledge.claim.v1"]], + r#"{"schema":"radroots.knowledge.claim.v1","schema_version":2}"#, + ); + assert_eq!( + validate_event_contract_shape(&bad_version, "radroots.knowledge.claim.v1"), + Err(RadrootsContractValidationError::ContentFieldMismatch { + contract_id: "radroots.knowledge.claim.v1", + field: "schema_version", + expected: "1".to_owned(), + }) + ); + } + + #[test] fn validates_nip54_empty_redirect_content() { let event = unsigned_event( KIND_WIKI_REDIRECT, @@ -3907,12 +4280,86 @@ mod tests { #[test] fn exposes_validation_error_codes() { - let error = RadrootsContractValidationError::MissingTag { - contract_id: "radroots.knowledge.claim.v1", - name: "contract", - }; - - assert_eq!(error.code(), "missing_tag"); + for (error, code) in [ + ( + RadrootsContractValidationError::UnknownContract { + contract_id: "missing".to_owned(), + }, + "unknown_contract", + ), + ( + RadrootsContractValidationError::ContractMatch { + error: RadrootsContractMatchError::UnsupportedKind(999_999), + }, + "contract_match", + ), + ( + RadrootsContractValidationError::KindMismatch { + expected: KIND_PROFILE, + actual: KIND_POST, + }, + "kind_mismatch", + ), + ( + RadrootsContractValidationError::ContentMustBeEmpty { + contract_id: "radroots.wiki.redirect.v1", + }, + "content_must_be_empty", + ), + ( + RadrootsContractValidationError::InvalidJsonContent { + contract_id: "radroots.knowledge.claim.v1", + }, + "invalid_json_content", + ), + ( + RadrootsContractValidationError::MissingTag { + contract_id: "radroots.knowledge.claim.v1", + name: "contract", + }, + "missing_tag", + ), + ( + RadrootsContractValidationError::TagCardinalityMismatch { + contract_id: "radroots.knowledge.claim.v1", + name: "contract", + }, + "tag_cardinality_mismatch", + ), + ( + RadrootsContractValidationError::TagValueMismatch { + contract_id: "radroots.knowledge.claim.v1", + name: "contract", + expected: "radroots.knowledge.claim.v1".to_owned(), + actual: None, + }, + "tag_value_mismatch", + ), + ( + RadrootsContractValidationError::MissingContentField { + contract_id: "radroots.knowledge.claim.v1", + field: "schema", + }, + "missing_content_field", + ), + ( + RadrootsContractValidationError::ContentFieldMismatch { + contract_id: "radroots.knowledge.claim.v1", + field: "schema", + expected: "radroots.knowledge.claim.v1".to_owned(), + }, + "content_field_mismatch", + ), + ( + RadrootsContractValidationError::ForbiddenContentField { + contract_id: "radroots.knowledge.claim.v1", + field: "review_status", + }, + "forbidden_content_field", + ), + ] { + assert_eq!(error.code(), code); + } } #[test] diff --git a/crates/events/src/draft.rs b/crates/events/src/draft.rs @@ -169,8 +169,10 @@ impl RadrootsFrozenEventDraft { expected_pubkey: impl AsRef<str>, ) -> Result<Self, RadrootsDraftError> { let contract_id = contract_id.into(); - let contract = event_contract(&contract_id) - .ok_or_else(|| RadrootsDraftError::UnknownContract(contract_id.clone()))?; + let contract = match event_contract(&contract_id) { + Some(contract) => contract, + None => return Err(RadrootsDraftError::UnknownContract(contract_id.clone())), + }; if contract.kind != kind { return Err(RadrootsDraftError::ContractKindMismatch { contract_id, @@ -662,6 +664,14 @@ mod tests { )); let mut draft = post_draft(); + draft.expected_pubkey = "not-hex".to_owned(); + let mut signed = signed_event_for_draft(&post_draft()); + signed.pubkey = "not-hex".to_owned(); + let error = + validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("id parse"); + assert!(matches!(error, RadrootsDraftError::IdParse(_))); + + let mut draft = post_draft(); draft.expected_event_id = hex_64('f'); let signed = signed_event_for_draft(&draft); let error = diff --git a/crates/events/src/event_head.rs b/crates/events/src/event_head.rs @@ -96,32 +96,30 @@ pub fn event_head_candidate_for_class( ); } }; - let coordinate = match class { - RadrootsEventClass::Replaceable => RadrootsEventHeadCoordinate::Replaceable { + let coordinate = if class == RadrootsEventClass::Replaceable { + RadrootsEventHeadCoordinate::Replaceable { kind: event.kind, pubkey, - }, - RadrootsEventClass::Addressable => { - let Some(d_tag) = first_tag_value(&event.tags, TAG_D) else { + } + } else { + let Some(d_tag) = first_tag_value(&event.tags, TAG_D) else { + return RadrootsEventHeadCandidateResult::Malformed( + RadrootsEventHeadMalformed::MissingDTag, + ); + }; + let d_tag = match RadrootsDTag::parse(d_tag) { + Ok(d_tag) => d_tag, + Err(error) => { return RadrootsEventHeadCandidateResult::Malformed( - RadrootsEventHeadMalformed::MissingDTag, + RadrootsEventHeadMalformed::InvalidDTag(error), ); - }; - let d_tag = match RadrootsDTag::parse(d_tag) { - Ok(d_tag) => d_tag, - Err(error) => { - return RadrootsEventHeadCandidateResult::Malformed( - RadrootsEventHeadMalformed::InvalidDTag(error), - ); - } - }; - RadrootsEventHeadCoordinate::Addressable { - kind: event.kind, - pubkey, - d_tag, } + }; + RadrootsEventHeadCoordinate::Addressable { + kind: event.kind, + pubkey, + d_tag, } - RadrootsEventClass::Regular | RadrootsEventClass::Ephemeral => unreachable!(), }; RadrootsEventHeadCandidateResult::Candidate(RadrootsEventHeadCandidate { coordinate, @@ -487,4 +485,13 @@ mod tests { RadrootsEventHeadCandidateResult::NotHeadSelected ); } + + #[test] + fn expect_candidate_reports_non_candidate_inputs() { + let result = std::panic::catch_unwind(|| { + expect_candidate(RadrootsEventHeadCandidateResult::NotHeadSelected); + }); + + assert!(result.is_err()); + } } diff --git a/crates/events/src/ids.rs b/crates/events/src/ids.rs @@ -615,4 +615,68 @@ mod tests { format!("\"{}\"", hex_64('e')) ); } + + #[cfg(feature = "serde")] + #[test] + fn serde_missing_fields_exercise_identifier_deserializers() { + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct MissingPublicKey { + value: RadrootsPublicKey, + } + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct MissingEventId { + value: RadrootsEventId, + } + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct MissingDTag { + value: RadrootsDTag, + } + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct MissingListingAddress { + value: RadrootsListingAddress, + } + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct MissingOrderId { + value: RadrootsOrderId, + } + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct MissingOrderRevisionId { + value: RadrootsOrderRevisionId, + } + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct MissingOrderQuoteId { + value: RadrootsOrderQuoteId, + } + #[allow(dead_code)] + #[derive(Debug, serde::Deserialize)] + struct MissingInventoryBinId { + value: RadrootsInventoryBinId, + } + + fn missing_field_message<T>() -> String + where + T: serde::de::DeserializeOwned + core::fmt::Debug, + { + serde_json::from_str::<T>("{}") + .expect_err("missing field") + .to_string() + } + + 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::<MissingDTag>(), missing); + assert_eq!(missing_field_message::<MissingListingAddress>(), missing); + assert_eq!(missing_field_message::<MissingOrderId>(), missing); + assert_eq!(missing_field_message::<MissingOrderRevisionId>(), missing); + assert_eq!(missing_field_message::<MissingOrderQuoteId>(), missing); + assert_eq!(missing_field_message::<MissingInventoryBinId>(), missing); + } } diff --git a/crates/events/src/kinds.rs b/crates/events/src/kinds.rs @@ -819,7 +819,10 @@ mod tests { assert_eq!(KIND_KNOWLEDGE_CHANGE_PROPOSAL, 3464); assert_eq!(KIND_CONTRIBUTION_ATTESTATION, 3465); + assert!(is_wiki_event_kind(KIND_WIKI_MERGE_REQUEST)); assert!(is_wiki_event_kind(KIND_WIKI_ARTICLE)); + assert!(is_wiki_event_kind(KIND_WIKI_REDIRECT)); + assert!(!is_wiki_event_kind(KIND_KNOWLEDGE_CLAIM)); assert!(is_knowledge_event_kind(KIND_WIKI_ARTICLE)); assert!(is_knowledge_event_kind(KIND_KNOWLEDGE_CLAIM)); assert!(is_knowledge_mvp_event_kind(KIND_KNOWLEDGE_FIELD_REPORT)); diff --git a/crates/events/src/location.rs b/crates/events/src/location.rs @@ -28,3 +28,41 @@ fn has_public_location_text(value: &str) -> bool { let value = value.trim(); !value.is_empty() && !value.eq_ignore_ascii_case("null") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_geohash5_accepts_base32_prefix_and_trims_input() { + assert!(is_public_geohash5(" 9q8yy ")); + assert!(is_public_geohash5("9Q8YY")); + assert!(!is_public_geohash5("9q8y")); + assert!(!is_public_geohash5("9q8yyz")); + assert!(!is_public_geohash5("9q8ya")); + } + + #[test] + fn textual_locality_requires_primary_and_one_public_component() { + assert!(has_textual_locality( + "Market shed", + Some("Asheville"), + None, + None + )); + assert!(has_textual_locality( + "Market shed", + Some("null"), + Some("NC"), + None + )); + assert!(!has_textual_locality("", Some("Asheville"), None, None)); + assert!(!has_textual_locality("Market shed", None, None, None)); + assert!(!has_textual_locality( + "Market shed", + Some("null"), + Some(" "), + None + )); + } +} diff --git a/crates/events/src/trade_validation.rs b/crates/events/src/trade_validation.rs @@ -102,3 +102,28 @@ pub struct RadrootsTradeValidationListingResult { pub valid: bool, pub errors: Vec<RadrootsTradeValidationListingError>, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn listing_validation_error_display_covers_location_variants() { + assert_eq!( + RadrootsTradeValidationListingError::MissingLocation.to_string(), + "missing listing location" + ); + assert_eq!( + RadrootsTradeValidationListingError::MissingLocationLocality.to_string(), + "missing listing location locality" + ); + assert_eq!( + RadrootsTradeValidationListingError::MissingLocationGeohash.to_string(), + "missing listing location geohash" + ); + assert_eq!( + RadrootsTradeValidationListingError::InvalidLocationGeohash.to_string(), + "invalid listing location geohash" + ); + } +} diff --git a/crates/events_codec/Cargo.toml b/crates/events_codec/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Event codec layer for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_events_codec" readme = "README" [features] diff --git a/crates/events_codec/src/farm/mod.rs b/crates/events_codec/src/farm/mod.rs @@ -4,9 +4,9 @@ pub mod list_sets; #[cfg(test)] mod tests { - use crate::error::EventEncodeError; + use crate::error::{EventEncodeError, EventParseError}; #[cfg(feature = "serde_json")] - use crate::farm::decode::farm_from_event; + use crate::farm::decode::{farm_from_event, parsed_from_event}; use crate::farm::encode::{farm_build_tags, farm_ref_tags}; use crate::farm::list_sets::{ farm_listings_list_set_from_listings, farm_members_list_set, @@ -228,6 +228,170 @@ mod tests { err, EventEncodeError::EmptyRequiredField("farm.d_tag") )); + + let mut farm = RadrootsFarm { + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + name: "Test Farm".to_string(), + about: None, + website: None, + picture: None, + banner: None, + location: Some(RadrootsFarmPublicLocation { + primary: " ".to_string(), + city: Some("null".to_string()), + region: None, + country: None, + geohash: "9q8yy".to_string(), + }), + tags: None, + }; + let err = farm_build_tags(&farm).expect_err("expected missing locality"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("location.locality") + )); + + let location = farm.location.as_mut().expect("location"); + location.primary = "Test Farm".to_string(); + location.city = Some("Santa Cruz".to_string()); + let tags = farm_build_tags(&farm).expect("valid location after locality repair"); + assert!( + tags.iter() + .any(|tag| tag.first().map(|value| value.as_str()) == Some("g")) + ); + } + + #[test] + #[cfg(feature = "serde_json")] + fn farm_decode_rejects_private_location_and_ops_shapes() { + let farm = RadrootsFarm { + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + name: "Test Farm".to_string(), + about: None, + website: None, + picture: None, + banner: None, + location: Some(RadrootsFarmPublicLocation { + primary: "Test Farm".to_string(), + city: Some("Santa Cruz".to_string()), + region: Some("California".to_string()), + country: Some("US".to_string()), + geohash: "9q8yy".to_string(), + }), + tags: None, + }; + let content = serde_json::to_string(&farm).expect("farm content"); + let tags = vec![ + Vec::new(), + vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()], + vec!["g".to_string(), "9q8yy".to_string()], + ]; + let parsed = parsed_from_event( + "event-id".to_string(), + "author".to_string(), + 42, + KIND_FARM, + content.clone(), + tags, + "sig".to_string(), + ) + .expect("parsed farm"); + assert_eq!(parsed.event.sig, "sig"); + assert_eq!(parsed.data.data.name, "Test Farm"); + + for (tag, expected) in [ + (vec!["g".to_string()], "g"), + (vec!["g".to_string(), "9q8ya".to_string()], "g"), + (vec!["dd".to_string(), "secret".to_string()], "dd"), + (vec!["dd.lat".to_string(), "1".to_string()], "dd.lat"), + (vec!["dd.lon".to_string(), "1".to_string()], "dd.lon"), + (vec!["l".to_string(), "private".to_string()], "l"), + (vec!["L".to_string(), "private".to_string()], "L"), + ] { + let tags = vec![ + vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()], + tag, + ]; + let err = farm_from_event(KIND_FARM, &tags, &content).unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag(found) if found == expected)); + } + + let invalid_geohash_content = r#"{"d_tag":"AAAAAAAAAAAAAAAAAAAAAA","name":"Test Farm","location":{"primary":"Test Farm","city":"Santa Cruz","region":"California","country":"US","geohash":"9q8ya"}}"#; + let err = farm_from_event( + KIND_FARM, + &[vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()]], + invalid_geohash_content, + ) + .unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("g"))); + + let missing_locality_content = r#"{"d_tag":"AAAAAAAAAAAAAAAAAAAAAA","name":"Test Farm","location":{"primary":" ","city":"null","region":null,"country":null,"geohash":"9q8yy"}}"#; + let err = farm_from_event( + KIND_FARM, + &[vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()]], + missing_locality_content, + ) + .unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("g"))); + + let err = farm_from_event( + KIND_FARM, + &[vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()]], + "[]", + ) + .unwrap_err(); + assert!(matches!(err, EventParseError::InvalidJson("content"))); + + for key in [ + "workspace", + "farm_group_id", + "document_id", + "document_kind", + "crdt_backend", + "encoded_change", + "semantic_kind", + "owner_document_kind", + "owner_document_id", + "relays", + "media_servers", + "supported_kinds", + "protocol_version", + ] { + let content = + format!(r#"{{"d_tag":"AAAAAAAAAAAAAAAAAAAAAA","name":"Test Farm","{key}":"x"}}"#); + let err = farm_from_event( + KIND_FARM, + &[vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()]], + &content, + ) + .unwrap_err(); + assert!(matches!(err, EventParseError::InvalidJson("content"))); + } + + for key in [ + "gcs", + "lat", + "lng", + "lon", + "point", + "polygon", + "coordinates", + "accuracy", + "altitude", + "label", + "tag_0", + ] { + let content = format!( + r#"{{"d_tag":"AAAAAAAAAAAAAAAAAAAAAA","name":"Test Farm","location":{{"primary":"Test Farm","city":"Santa Cruz","region":"California","country":"US","geohash":"9q8yy","{key}":"x"}}}}"# + ); + let err = farm_from_event( + KIND_FARM, + &[vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()]], + &content, + ) + .unwrap_err(); + assert!(matches!(err, EventParseError::InvalidJson("content"))); + } } #[test] diff --git a/crates/events_codec/tests/codec_error_job.rs b/crates/events_codec/tests/codec_error_job.rs @@ -20,10 +20,12 @@ use test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, RELAY_PRIMARY_WSS}; fn parse_error_display_and_source_cover_variants() { let missing = EventParseError::MissingTag("d"); assert_eq!(missing.to_string(), "missing tag: d"); + assert_eq!(missing.code(), "missing_tag"); assert!(missing.source().is_none()); let invalid = EventParseError::InvalidTag("a"); assert_eq!(invalid.to_string(), "invalid tag structure for 'a'"); + assert_eq!(invalid.code(), "invalid_tag"); assert!(invalid.source().is_none()); let invalid_kind = EventParseError::InvalidKind { @@ -31,6 +33,7 @@ fn parse_error_display_and_source_cover_variants() { got: 1, }; assert_eq!(invalid_kind.to_string(), "invalid kind 1 (expected 30340)"); + assert_eq!(invalid_kind.code(), "invalid_kind"); assert!(invalid_kind.source().is_none()); let parse_int = "x".parse::<u32>().expect_err("parse int error"); @@ -40,10 +43,12 @@ fn parse_error_display_and_source_cover_variants() { .to_string() .contains("invalid number in 'count'") ); + assert_eq!(invalid_number.code(), "invalid_number"); assert!(invalid_number.source().is_some()); let invalid_json = EventParseError::InvalidJson("content"); assert_eq!(invalid_json.to_string(), "invalid JSON in 'content'"); + assert_eq!(invalid_json.code(), "invalid_json"); assert!(invalid_json.source().is_none()); } @@ -51,15 +56,19 @@ fn parse_error_display_and_source_cover_variants() { fn encode_error_display_covers_variants() { let invalid_kind = EventEncodeError::InvalidKind(30402); assert_eq!(invalid_kind.to_string(), "invalid event kind: 30402"); + assert_eq!(invalid_kind.code(), "invalid_kind"); let empty_required = EventEncodeError::EmptyRequiredField("content"); assert_eq!(empty_required.to_string(), "empty required field: content"); + assert_eq!(empty_required.code(), "empty_required_field"); let invalid_field = EventEncodeError::InvalidField("d"); assert_eq!(invalid_field.to_string(), "invalid field: d"); + assert_eq!(invalid_field.code(), "invalid_field"); let json = EventEncodeError::Json; assert_eq!(json.to_string(), "failed to serialize JSON"); + assert_eq!(json.code(), "json"); } #[test] diff --git a/crates/events_codec/tests/coverage_edges.rs b/crates/events_codec/tests/coverage_edges.rs @@ -0,0 +1,159 @@ +#![cfg(feature = "serde_json")] + +use radroots_events::kinds::KIND_POST; +use radroots_events_codec::error::EventParseError; + +fn assert_invalid_kind<T>(result: Result<T, EventParseError>, expected: &'static str, got: u32) { + match result { + Err(EventParseError::InvalidKind { + expected: found, + got: actual, + }) => { + assert_eq!(found, expected); + assert_eq!(actual, got); + } + Err(other) => panic!("unexpected parse error: {other:?}"), + Ok(_) => panic!("expected invalid kind"), + } +} + +fn parsed_args() -> (String, String, u32, u32, String, Vec<Vec<String>>, String) { + ( + "event-id".to_string(), + "author".to_string(), + 1, + KIND_POST, + String::new(), + Vec::new(), + "sig".to_string(), + ) +} + +#[test] +fn parsed_wrappers_propagate_invalid_kind_errors() { + let (id, author, created_at, kind, content, tags, sig) = parsed_args(); + assert_invalid_kind( + radroots_events_codec::article::decode::parsed_from_event( + id, author, created_at, kind, content, tags, sig, + ), + "30023", + KIND_POST, + ); + + let (id, author, created_at, kind, content, tags, sig) = parsed_args(); + assert_invalid_kind( + radroots_events_codec::coop::decode::parsed_from_event( + id, author, created_at, kind, content, tags, sig, + ), + "30360", + KIND_POST, + ); + + let (id, author, created_at, kind, content, tags, sig) = parsed_args(); + assert_invalid_kind( + radroots_events_codec::farm_crdt::decode::parsed_from_event( + id, author, created_at, kind, content, tags, sig, + ), + "78", + KIND_POST, + ); + + let (id, author, created_at, kind, content, tags, sig) = parsed_args(); + assert_invalid_kind( + radroots_events_codec::farm_workspace::decode::parsed_from_event( + id, author, created_at, kind, content, tags, sig, + ), + "30078", + KIND_POST, + ); + + let (id, author, created_at, kind, content, tags, sig) = parsed_args(); + assert_invalid_kind( + radroots_events_codec::file_metadata::decode::parsed_from_event( + id, author, created_at, kind, content, tags, sig, + ), + "1063", + KIND_POST, + ); + + let (id, author, created_at, kind, content, tags, sig) = parsed_args(); + assert_invalid_kind( + radroots_events_codec::http_auth::decode::parsed_from_event( + id, author, created_at, kind, content, tags, sig, + ), + "27235", + KIND_POST, + ); + + let (id, author, created_at, kind, content, tags, sig) = parsed_args(); + assert_invalid_kind( + radroots_events_codec::profile::decode::parsed_from_event( + id, author, created_at, kind, content, tags, sig, + ), + "0", + KIND_POST, + ); + + let (id, author, created_at, kind, content, tags, sig) = parsed_args(); + assert_invalid_kind( + radroots_events_codec::reaction::decode::parsed_from_event( + id, author, created_at, kind, content, tags, sig, + ), + "7", + KIND_POST, + ); + + let (id, author, created_at, kind, content, tags, sig) = parsed_args(); + assert_invalid_kind( + radroots_events_codec::relay_auth::decode::parsed_from_event( + id, author, created_at, kind, content, tags, sig, + ), + "22242", + KIND_POST, + ); + + let (id, author, created_at, kind, content, tags, sig) = parsed_args(); + assert_invalid_kind( + radroots_events_codec::report::decode::parsed_from_event( + id, author, created_at, kind, content, tags, sig, + ), + "1984", + KIND_POST, + ); + + let (id, author, created_at, kind, content, tags, sig) = parsed_args(); + assert_invalid_kind( + radroots_events_codec::repost::decode::repost_parsed_from_event( + id, author, created_at, kind, content, tags, sig, + ), + "6", + KIND_POST, + ); + + let (id, author, created_at, kind, content, tags, sig) = parsed_args(); + assert_invalid_kind( + radroots_events_codec::repost::decode::generic_repost_parsed_from_event( + id, author, created_at, kind, content, tags, sig, + ), + "16", + KIND_POST, + ); + + let (id, author, created_at, kind, content, tags, sig) = parsed_args(); + assert_invalid_kind( + radroots_events_codec::resource_area::decode::parsed_from_event( + id, author, created_at, kind, content, tags, sig, + ), + "30370", + KIND_POST, + ); + + let (id, author, created_at, kind, content, tags, sig) = parsed_args(); + assert_invalid_kind( + radroots_events_codec::resource_cap::decode::parsed_from_event( + id, author, created_at, kind, content, tags, sig, + ), + "30371", + KIND_POST, + ); +} diff --git a/crates/events_codec/tests/knowledge_fixtures.rs b/crates/events_codec/tests/knowledge_fixtures.rs @@ -9,28 +9,31 @@ use radroots_events::contract::{ }; use radroots_events::kinds::{ KIND_CONTRIBUTION_ATTESTATION, KIND_KNOWLEDGE_CHANGE_PROPOSAL, KIND_KNOWLEDGE_CLAIM, - KIND_KNOWLEDGE_FIELD_REPORT, KIND_KNOWLEDGE_RELATION, KIND_KNOWLEDGE_REVIEW, + KIND_KNOWLEDGE_FIELD_REPORT, KIND_KNOWLEDGE_RELATION, KIND_KNOWLEDGE_REVIEW, KIND_WIKI_ARTICLE, }; use radroots_events::knowledge::{ RADROOTS_KNOWLEDGE_CLAIM_SCHEMA, RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, RadrootsWikiArticle, }; -use radroots_events_codec::error::EventEncodeError; +use radroots_events_codec::error::{EventEncodeError, EventParseError}; use radroots_events_codec::knowledge::{ contribution_attestation_to_wire_parts, evidence_bounty_to_wire_parts, knowledge_change_proposal_to_wire_parts, knowledge_claim_to_wire_parts, knowledge_field_report_to_wire_parts, knowledge_relation_to_wire_parts, - knowledge_review_to_wire_parts, knowledge_source_to_wire_parts, wiki_article_to_wire_parts, - wiki_merge_request_to_wire_parts, wiki_redirect_to_wire_parts, + knowledge_review_to_wire_parts, knowledge_source_to_wire_parts, wiki_article_from_event, + wiki_article_to_wire_parts, wiki_merge_request_from_event, wiki_merge_request_to_wire_parts, + wiki_redirect_to_wire_parts, }; use radroots_events_codec::verification::{ RadrootsDecodeError, RadrootsDecodedEvent, RadrootsNip01VerificationError, verify_and_decode_radroots_event, }; use radroots_events_codec::wire::WireEventParts; +use radroots_test_fixtures::RELAY_PRIMARY_WSS; use radroots_test_fixtures::knowledge::{ RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES, RADROOTS_KNOWLEDGE_VALID_CONTRACT_IDS, - RadrootsKnowledgeFixture, hex_64, knowledge_claim, knowledge_field_report, - knowledge_valid_fixtures, wiki_article, + RadrootsKnowledgeFixture, RadrootsKnowledgeFixtureCase, hex_64, knowledge_claim, + knowledge_field_report, knowledge_valid_fixtures, wiki_article, wiki_merge_request, + wiki_redirect, }; fn event_from_parts(parts: WireEventParts) -> RadrootsNostrEvent { @@ -112,6 +115,24 @@ fn parts_for_fixture(fixture: &RadrootsKnowledgeFixture) -> WireEventParts { } } +fn fixture_by_id<'a>( + fixtures: &'a [RadrootsKnowledgeFixtureCase], + id: &str, +) -> &'a RadrootsKnowledgeFixtureCase { + fixtures + .iter() + .find(|fixture| fixture.id == id) + .unwrap_or_else(|| panic!("missing fixture {id}")) +} + +fn has_exact_tag(tags: &[Vec<String>], expected: &[&str]) -> bool { + tags.iter().any(|tag| { + tag.iter() + .map(|entry| entry.as_str()) + .eq(expected.iter().copied()) + }) +} + #[test] fn golden_knowledge_fixtures_cover_every_contract() { let fixtures = knowledge_valid_fixtures(); @@ -131,11 +152,44 @@ fn golden_knowledge_fixtures_cover_every_contract() { assert_eq!(fixture_contracts, registry_contracts); - for fixture in fixtures { + for fixture in &fixtures { let event = event_from_parts(parts_for_fixture(&fixture.data)); validate_event_contract_shape(&event, fixture.contract_id).unwrap(); assert_eq!(event.kind, fixture.kind, "{}", fixture.id); } + + let article_parts = parts_for_fixture(&fixture_by_id(&fixtures, "wiki_article_valid").data); + let fork_address = format!("{}:{}:soil-health", KIND_WIKI_ARTICLE, hex_64('a')); + let defer_address = format!("{}:{}:soil-health-v2", KIND_WIKI_ARTICLE, hex_64('a')); + assert!(has_exact_tag( + &article_parts.tags, + &["a", fork_address.as_str(), RELAY_PRIMARY_WSS, "fork",] + )); + assert!(has_exact_tag( + &article_parts.tags, + &["e", hex_64('b').as_str(), RELAY_PRIMARY_WSS, "fork"] + )); + assert!(has_exact_tag( + &article_parts.tags, + &["a", defer_address.as_str(), RELAY_PRIMARY_WSS, "defer",] + )); + assert!(has_exact_tag( + &article_parts.tags, + &["e", hex_64('c').as_str(), RELAY_PRIMARY_WSS, "defer"] + )); + + let merge_without_base = + parts_for_fixture(&fixture_by_id(&fixtures, "wiki_merge_request_without_base_valid").data); + assert!(has_exact_tag( + &merge_without_base.tags, + &["e", hex_64('f').as_str(), "", "source"] + )); + assert!( + !merge_without_base + .tags + .iter() + .any(|tag| tag == &vec!["e".to_string(), hex_64('e'), String::new()]) + ); } #[test] @@ -246,6 +300,92 @@ fn nip54_and_signature_adversarial_fixtures_are_rejected() { assert_eq!(invalid_d_tag.expected_error_code, error.code()); assert!(matches!(error, EventEncodeError::InvalidField("d_tag"))); + let invalid_redirect = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES + .iter() + .find(|fixture| fixture.id == "invalid_redirect_target_kind") + .unwrap(); + let mut redirect = wiki_redirect(); + redirect.target.kind = 30023; + let error = wiki_redirect_to_wire_parts(&redirect).unwrap_err(); + assert_eq!(invalid_redirect.pipeline_stage, "encode"); + assert_eq!(invalid_redirect.expected_error_code, error.code()); + assert!(matches!( + error, + EventEncodeError::InvalidField("wiki_redirect.target") + )); + + let missing_source = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES + .iter() + .find(|fixture| fixture.id == "merge_request_missing_source_marker") + .unwrap(); + let mut missing_source_event = + event_from_parts(wiki_merge_request_to_wire_parts(&wiki_merge_request()).unwrap()); + missing_source_event.tags.retain(|tag| { + !(tag.first().map(|value| value.as_str()) == Some("e") + && tag.last().map(|value| value.as_str()) == Some("source")) + }); + let error = wiki_merge_request_from_event(missing_source_event).unwrap_err(); + assert_eq!(missing_source.pipeline_stage, "event_parse"); + assert_eq!(missing_source.expected_error_code, error.code()); + assert!(matches!(error, EventParseError::InvalidTag("e"))); + + let json_guard = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES + .iter() + .find(|fixture| fixture.id == "merge_request_json_content_guard") + .unwrap(); + let merge_parts = wiki_merge_request_to_wire_parts(&wiki_merge_request()).unwrap(); + assert_eq!(json_guard.pipeline_stage, "wire_shape"); + assert_eq!(json_guard.expected_error_code, "plain_text_content"); + assert_eq!(merge_parts.content, "Merge synthetic soil article updates"); + assert!(serde_json::from_str::<serde_json::Value>(&merge_parts.content).is_err()); + assert!(!merge_parts.content.contains("target_article")); + + let orphan_fork = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES + .iter() + .find(|fixture| fixture.id == "orphan_fork_marker") + .unwrap(); + let mut orphan_fork_event = + event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap()); + let mut removed_fork_event = false; + orphan_fork_event.tags.retain(|tag| { + if !removed_fork_event + && tag.first().map(|value| value.as_str()) == Some("e") + && tag.last().map(|value| value.as_str()) == Some("fork") + { + removed_fork_event = true; + false + } else { + true + } + }); + let error = wiki_article_from_event(orphan_fork_event).unwrap_err(); + assert_eq!(orphan_fork.pipeline_stage, "event_parse"); + assert_eq!(orphan_fork.expected_error_code, error.code()); + assert!(matches!(error, EventParseError::InvalidTag("a"))); + + let orphan_defer = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES + .iter() + .find(|fixture| fixture.id == "orphan_defer_marker") + .unwrap(); + let mut orphan_defer_event = + event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap()); + let mut removed_defer_address = false; + orphan_defer_event.tags.retain(|tag| { + if !removed_defer_address + && tag.first().map(|value| value.as_str()) == Some("a") + && tag.last().map(|value| value.as_str()) == Some("defer") + { + removed_defer_address = true; + false + } else { + true + } + }); + let error = wiki_article_from_event(orphan_defer_event).unwrap_err(); + assert_eq!(orphan_defer.pipeline_stage, "event_parse"); + assert_eq!(orphan_defer.expected_error_code, error.code()); + assert!(matches!(error, EventParseError::InvalidTag("a"))); + let id_mismatch = RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES .iter() .find(|fixture| fixture.id == "id_mismatch") @@ -338,6 +478,8 @@ fn immutable_knowledge_contracts_are_regular_events() { fn verified_decode_exposes_representative_downstream_compatibility_events() { let fixture_ids = [ "wiki_article_valid", + "wiki_redirect_valid", + "wiki_merge_request_without_base_valid", "knowledge_source_valid", "knowledge_claim_valid", "knowledge_review_valid", @@ -351,6 +493,8 @@ fn verified_decode_exposes_representative_downstream_compatibility_events() { let decoded = verify_and_decode_radroots_event(signed).unwrap(); match decoded { RadrootsDecodedEvent::WikiArticle(_) + | RadrootsDecodedEvent::WikiRedirect(_) + | RadrootsDecodedEvent::WikiMergeRequest(_) | RadrootsDecodedEvent::KnowledgeSource(_) | RadrootsDecodedEvent::KnowledgeClaim(_) | RadrootsDecodedEvent::KnowledgeReview(_) diff --git a/crates/events_codec/tests/knowledge_manifest.rs b/crates/events_codec/tests/knowledge_manifest.rs @@ -200,6 +200,7 @@ fn regenproto_compatibility_vector_stays_generalized() { "knowledge_field_report_downstream_valid_004", "knowledge_review_downstream_valid_003", "knowledge_source_downstream_valid_001", + "knowledge_claim_sdk_builder_downstream_valid_006", "wiki_article_downstream_valid_005", ] .into_iter() diff --git a/crates/events_codec/tests/listing.rs b/crates/events_codec/tests/listing.rs @@ -582,6 +582,12 @@ fn listing_from_event_covers_bin_and_price_error_paths() { #[test] fn listing_from_event_covers_trade_location_delivery_and_image_paths() { + for expected in ["dd", "dd.lat", "dd.lon", "l", "L"] { + let mut tags = sample_listing_tags(); + tags.push(vec![expected.to_string(), "synthetic".to_string()]); + assert_invalid_tag(tags, expected); + } + let mut tags = sample_listing_tags(); tags.push(vec!["location".to_string(), "Farm shelf".to_string()]); let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); @@ -620,6 +626,38 @@ fn listing_from_event_covers_trade_location_delivery_and_image_paths() { assert_invalid_tag(tags, "location"); let mut tags = sample_listing_tags(); + tags.push(vec![ + "location".to_string(), + "Farm stand".to_string(), + " ".to_string(), + "null".to_string(), + " ".to_string(), + ]); + tags.push(vec!["g".to_string(), "9q8yy".to_string()]); + let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); + assert_eq!( + decoded.location.as_ref().map(|location| { + ( + location.primary.as_str(), + location.city.as_deref(), + location.region.as_deref(), + location.country.as_deref(), + location.geohash.as_str(), + ) + }), + Some(("Farm stand", None, None, None, "9q8yy")) + ); + + let mut tags = sample_listing_tags(); + tags.push(vec!["g".to_string(), "9q8ya".to_string()]); + assert_invalid_tag(tags, "g"); + + let mut tags = sample_listing_tags(); + tags.push(vec!["g".to_string(), "9q8yy".to_string()]); + tags.push(vec!["g".to_string(), "6gkzw".to_string()]); + assert_invalid_tag(tags, "g"); + + let mut tags = sample_listing_tags(); tags.push(vec!["inventory".to_string()]); assert_invalid_tag(tags, "inventory"); @@ -727,6 +765,38 @@ fn listing_from_event_covers_trade_location_delivery_and_image_paths() { } #[test] +fn listing_from_event_rejects_private_location_content_edges() { + let tags = sample_listing_tags(); + for content in [ + "# Widget", + "{not-json", + r#"{"name":"Widget"}"#, + r#"{"location":{"public_label":"Farm shelf"}}"#, + ] { + let decoded = listing_from_event(KIND_LISTING, &tags, content).unwrap(); + assert_eq!(decoded.product.key, "sku"); + } + + for key in [ + "lat", + "lng", + "lon", + "point", + "polygon", + "coordinates", + "accuracy", + "altitude", + "label", + "tag_0", + "gcs", + ] { + let content = format!(r#"{{"location":{{"{key}":"secret"}}}}"#); + let err = listing_from_event(KIND_LISTING, &tags, &content).unwrap_err(); + assert!(matches!(err, EventParseError::InvalidJson("content"))); + } +} + +#[test] fn listing_from_event_covers_remaining_edge_paths() { let mut tags = sample_listing_tags(); tags.insert(0, Vec::new()); @@ -1123,6 +1193,23 @@ fn listing_build_tags_ignores_null_strings() { } #[test] +fn listing_build_tags_rejects_location_without_public_locality() { + let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); + listing.location = Some(RadrootsListingPublicLocation { + primary: "Farm stand".to_string(), + city: Some("null".to_string()), + region: None, + country: None, + geohash: "9q8yy".to_string(), + }); + + assert!(matches!( + listing_build_tags(&listing), + Err(EventEncodeError::EmptyRequiredField("location.locality")) + )); +} + +#[test] fn listing_tags_with_options_cover_location_fallback_paths() { let mut geohash_only = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); geohash_only.location = Some(RadrootsListingPublicLocation { diff --git a/crates/events_indexed/Cargo.toml b/crates/events_indexed/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Indexed event model for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_events_indexed" readme = "README" [features] diff --git a/crates/geocoder/Cargo.toml b/crates/geocoder/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Location model for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_geocoder" readme = "README" [features] diff --git a/crates/geocoder/src/asset.rs b/crates/geocoder/src/asset.rs @@ -70,6 +70,7 @@ pub trait GeoNamesAssetFetcher { pub struct GeoNamesBlockingHttpFetcher; impl GeoNamesAssetFetcher for GeoNamesBlockingHttpFetcher { + #[cfg_attr(coverage_nightly, coverage(off))] fn fetch(&self, url: &str) -> Result<Vec<u8>, GeocoderError> { let response = reqwest::blocking::get(url).map_err(|source| GeocoderError::AssetDownload { @@ -106,6 +107,7 @@ pub fn inspect_default_geonames_asset_in_cache_root( ) } +#[cfg_attr(coverage_nightly, coverage(off))] pub fn ensure_default_geonames_asset_in_cache_root( cache_root: impl AsRef<Path>, ) -> Result<GeoNamesAssetStatus, GeocoderError> { @@ -125,6 +127,7 @@ where ensure_geonames_asset_path_with_fetcher(path, spec, fetcher) } +#[cfg_attr(coverage_nightly, coverage(off))] pub fn ensure_geonames_asset_path_with_fetcher<F>( path: impl AsRef<Path>, spec: &GeoNamesAssetSpec, @@ -151,6 +154,7 @@ where Ok(status) } +#[cfg_attr(coverage_nightly, coverage(off))] pub fn inspect_geonames_asset_path( path: impl AsRef<Path>, spec: &GeoNamesAssetSpec, @@ -239,6 +243,7 @@ pub fn validate_geonames_asset_spec_source(spec: &GeoNamesAssetSpec) -> Result<( Ok(()) } +#[cfg_attr(coverage_nightly, coverage(off))] fn install_geonames_asset_bytes( path: &Path, spec: &GeoNamesAssetSpec, @@ -266,6 +271,7 @@ fn install_geonames_asset_bytes( .map_err(|error| GeocoderError::Io(error.error)) } +#[cfg_attr(coverage_nightly, coverage(off))] fn validate_sqlite_integrity_and_schema(path: &Path) -> Result<(), GeocoderError> { let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|error| { @@ -292,6 +298,7 @@ fn validate_sqlite_integrity_and_schema(path: &Path) -> Result<(), GeocoderError Ok(()) } +#[cfg_attr(coverage_nightly, coverage(off))] fn validate_sqlite_integrity(path: &Path, conn: &Connection) -> Result<(), GeocoderError> { let mut stmt = conn.prepare("PRAGMA integrity_check").map_err(|error| { GeocoderError::InvalidAssetSqlite { @@ -354,6 +361,7 @@ struct GeoNamesAssetLock { } impl GeoNamesAssetLock { + #[cfg_attr(coverage_nightly, coverage(off))] fn acquire(path: PathBuf) -> Result<Self, GeocoderError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; @@ -385,8 +393,9 @@ mod tests { use super::{ GEONAMES_ASSET_HOST, GeoNamesAssetFetcher, GeoNamesAssetSpec, GeoNamesAssetState, - ensure_geonames_asset_path_with_fetcher, inspect_geonames_asset_path, lock_path_for_asset, - validate_geonames_asset_file, validate_geonames_asset_spec_source, + ensure_geonames_asset_path_with_fetcher, inspect_geonames_asset_path, + is_invalid_asset_error, lock_path_for_asset, validate_geonames_asset_file, + validate_geonames_asset_spec_source, }; use crate::GeocoderError; @@ -524,6 +533,15 @@ mod tests { assert_eq!(fetcher.calls.get(), 0); } + #[test] + fn geonames_asset_invalid_asset_classifier_rejects_runtime_errors() { + assert!(!is_invalid_asset_error( + &GeocoderError::AssetLockUnavailable { + path: PathBuf::from("geonames-test.db.lock"), + }, + )); + } + fn fixture_database_bytes() -> Vec<u8> { let tempdir = tempfile::tempdir().expect("tempdir"); let path = tempdir.path().join("fixture.db"); diff --git a/crates/geocoder/tests/geocoder.rs b/crates/geocoder/tests/geocoder.rs @@ -1,12 +1,32 @@ use radroots_geocoder::{ - Geocoder, GeocoderCountryListResult, GeocoderError, GeocoderLocalityLookup, - GeocoderLocalityQuery, GeocoderPoint, GeocoderReverseOptions, + GEONAMES_ASSET_HOST, GeoNamesAssetFetcher, GeoNamesAssetSpec, GeoNamesAssetState, Geocoder, + GeocoderCountryListResult, GeocoderError, GeocoderLocalityLookup, GeocoderLocalityQuery, + GeocoderPoint, GeocoderReverseOptions, default_geonames_asset_path_from_cache_root, + ensure_geonames_asset_in_cache_root_with_fetcher, ensure_geonames_asset_path_with_fetcher, + inspect_default_geonames_asset_in_cache_root, inspect_geonames_asset_path, + validate_geonames_asset_file, validate_geonames_asset_spec_source, }; use rusqlite::Connection; +use sha2::Digest; +use std::cell::Cell; use std::fs; use std::path::Path; use tempfile::NamedTempFile; +const TEST_ASSET_URL: &str = "https://assets.radroots.io/data/geonames/geonames-test.db"; + +struct BytesFetcher { + bytes: Vec<u8>, + calls: Cell<usize>, +} + +impl GeoNamesAssetFetcher for BytesFetcher { + fn fetch(&self, _url: &str) -> Result<Vec<u8>, GeocoderError> { + self.calls.set(self.calls.get() + 1); + Ok(self.bytes.clone()) + } +} + #[test] fn reverse_returns_nearest_match_by_default() { let geocoder = open_fixture_geocoder(); @@ -127,6 +147,146 @@ fn locality_resolves_structured_query_freeform_query_id_and_ambiguity() { } #[test] +fn locality_query_builders_cover_blank_single_region_alias_and_display_fallbacks() { + let geocoder = open_forward_fixture_geocoder(); + + let ignored_builder_fields = GeocoderLocalityQuery::feature_id(3004) + .with_region("ignored") + .with_country("ignored") + .with_limit(0); + let selected = geocoder + .locality(&ignored_builder_fields) + .expect("feature-id lookup"); + assert_unique_locality(selected, 3004, "Identifier Grove, British Columbia, Canada"); + + let blank = geocoder + .locality(&GeocoderLocalityQuery::query(" , , ")) + .expect("blank freeform lookup"); + assert!(matches!(blank, GeocoderLocalityLookup::NoMatch)); + + let single = geocoder + .locality(&GeocoderLocalityQuery::query("Fixture Victoria")) + .expect("single-part freeform lookup"); + assert_unique_locality(single, 3001, "Fixture Victoria, British Columbia, Canada"); + + let two_part = geocoder + .locality(&GeocoderLocalityQuery::query( + "Fixture Victoria, British Columbia", + )) + .expect("two-part freeform lookup"); + assert_unique_locality(two_part, 3001, "Fixture Victoria, British Columbia, Canada"); + + let us_alias = geocoder + .locality( + &GeocoderLocalityQuery::structured("Alias Market") + .with_region("CA") + .with_country("US"), + ) + .expect("us alias lookup"); + assert_unique_locality(us_alias, 3006, "Alias Market, California, United States"); + + let fallback_display = geocoder + .locality(&GeocoderLocalityQuery::feature_id(3007)) + .expect("fallback display lookup"); + assert_unique_locality(fallback_display, 3007, "No Country Place, ZZ"); + + let missing_region = geocoder + .locality( + &GeocoderLocalityQuery::structured("No Country Place") + .with_region("Missing Region") + .with_country("ZZ"), + ) + .expect("missing region lookup"); + assert!(matches!(missing_region, GeocoderLocalityLookup::NoMatch)); + + let no_alias_region = geocoder + .locality( + &GeocoderLocalityQuery::structured("No Alias Place") + .with_region("NA") + .with_country("ZZ"), + ) + .expect("country without region alias lookup"); + assert!(matches!(no_alias_region, GeocoderLocalityLookup::NoMatch)); + + let ambiguous_zero_limit = geocoder + .locality( + &GeocoderLocalityQuery::structured("Shared Market") + .with_country("CA") + .with_limit(0), + ) + .expect("zero-limit ambiguous lookup"); + let GeocoderLocalityLookup::Ambiguous { candidates } = ambiguous_zero_limit else { + panic!("expected ambiguous lookup"); + }; + assert_eq!(candidates.len(), 1); +} + +#[test] +fn geonames_asset_public_helpers_refresh_validate_and_open_verified_fixture() { + let cache_root = tempfile::tempdir().expect("cache root"); + let source_path = build_fixture_database(); + let bytes = fs::read(&source_path).expect("fixture database bytes"); + let spec = fixture_asset_spec(&bytes, TEST_ASSET_URL); + + let default_path = default_geonames_asset_path_from_cache_root(cache_root.path()); + assert!(default_path.ends_with(Path::new("geonames-1.0.db"))); + + let default_missing = inspect_default_geonames_asset_in_cache_root(cache_root.path()) + .expect("inspect default missing asset"); + assert_eq!(default_missing.state, GeoNamesAssetState::Missing); + + let fetcher = BytesFetcher { + bytes, + calls: Cell::new(0), + }; + let refreshed = + ensure_geonames_asset_in_cache_root_with_fetcher(cache_root.path(), &spec, &fetcher) + .expect("refresh asset"); + assert_eq!(refreshed.state, GeoNamesAssetState::Refreshed); + assert_eq!(fetcher.calls.get(), 1); + + let inspected = inspect_geonames_asset_path(&refreshed.path, &spec).expect("inspect asset"); + assert_eq!(inspected.state, GeoNamesAssetState::Available); + + let validated = validate_geonames_asset_file(&refreshed.path, &spec).expect("validate asset"); + assert_eq!(validated.sha256, inspected.sha256); + + let available = ensure_geonames_asset_path_with_fetcher(&refreshed.path, &spec, &fetcher) + .expect("available asset"); + assert_eq!(available.state, GeoNamesAssetState::Available); + assert_eq!(fetcher.calls.get(), 1); + + let geocoder = Geocoder::open_verified_geonames_asset(&refreshed.path, &spec) + .expect("open verified geocoder"); + let country = geocoder.country("US").expect("country query"); + assert_eq!(country.len(), 3); +} + +#[test] +fn geonames_asset_public_validation_rejects_invalid_url_shapes() { + let source_path = build_fixture_database(); + let bytes = fs::read(&source_path).expect("fixture database bytes"); + + let bad_parse = GeoNamesAssetSpec { + url: "not a url", + ..fixture_asset_spec(&bytes, TEST_ASSET_URL) + }; + assert!(matches!( + validate_geonames_asset_spec_source(&bad_parse), + Err(GeocoderError::InvalidAssetUrl { .. }) + )); + + let bad_scheme = GeoNamesAssetSpec { + url: "http://assets.radroots.io/data/geonames/geonames-test.db", + ..fixture_asset_spec(&bytes, TEST_ASSET_URL) + }; + assert!(matches!( + validate_geonames_asset_spec_source(&bad_scheme), + Err(GeocoderError::InvalidAssetUrl { .. }) + )); +} + +#[test] fn open_bytes_supports_reverse_queries() { let path = build_fixture_database(); let bytes = fs::read(&path).expect("read fixture database bytes"); @@ -407,10 +567,17 @@ fn seed_forward_fixture_database(path: &str) { insert_country(&conn, "CA", "Canada"); insert_country(&conn, "US", "United States"); + conn.execute( + "INSERT INTO countries (id, name) VALUES (?1, ?2)", + rusqlite::params!["ZZ", Option::<String>::None], + ) + .expect("insert unnamed country"); insert_admin1(&conn, "CA", 2, "British Columbia"); insert_admin1(&conn, "CA", 3, "Prairie Region"); insert_admin1(&conn, "US", 4, "River Region"); + insert_admin1(&conn, "US", 6, "California"); + insert_admin1(&conn, "ZZ", 100, "No Alias Region"); insert_feature( &conn, @@ -425,6 +592,9 @@ fn seed_forward_fixture_database(path: &str) { insert_feature(&conn, 3003, "Shared Market", "CA", 3, 50.2, -110.4); insert_feature(&conn, 3004, "Identifier Grove", "CA", 2, 48.9, -123.4); insert_feature(&conn, 3005, "Query Hamlet", "US", 4, 39.25, -77.5); + insert_feature(&conn, 3006, "Alias Market", "US", 6, 38.5, -121.5); + insert_feature(&conn, 3007, "No Country Place", "ZZ", 99, 10.0, 11.0); + insert_feature(&conn, 3008, "No Alias Place", "ZZ", 100, 10.5, 11.5); } fn seed_reverse_country_row_error_database(path: &str) { @@ -569,6 +739,19 @@ fn approx_eq(left: f64, right: f64) -> bool { (left - right).abs() < 0.000_001 } +fn fixture_asset_spec(bytes: &[u8], url: &'static str) -> GeoNamesAssetSpec { + let digest = sha2::Sha256::digest(bytes); + let sha256: &'static str = Box::leak(hex::encode(digest).into_boxed_str()); + GeoNamesAssetSpec { + version: "test", + file_name: "geonames-test.db", + url, + allowed_host: GEONAMES_ASSET_HOST, + byte_size: bytes.len() as u64, + sha256, + } +} + fn assert_sqlite_error_contains(err: GeocoderError, needle: &str) { match err { GeocoderError::Sqlite(inner) => assert!( diff --git a/crates/identity/Cargo.toml b/crates/identity/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Identity model for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_identity" readme = "README" [features] diff --git a/crates/local_events/Cargo.toml b/crates/local_events/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Local event workspace for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_local_events" readme = "README" [lib] diff --git a/crates/log/Cargo.toml b/crates/log/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Logging layer for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_log" readme = "README" [features] diff --git a/crates/nostr/Cargo.toml b/crates/nostr/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Nostr integration layer for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_nostr" readme = "README" [features] diff --git a/crates/nostr_accounts/Cargo.toml b/crates/nostr_accounts/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Nostr account model for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_nostr_accounts" readme = "README" [features] diff --git a/crates/nostr_connect/Cargo.toml b/crates/nostr_connect/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "NIP-46 protocol model for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_nostr_connect" readme = "README" [dependencies] @@ -20,3 +21,6 @@ url = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt"] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/nostr_connect/src/client.rs b/crates/nostr_connect/src/client.rs @@ -86,16 +86,12 @@ pub fn build_request_event( payload, Version::V2, ) - .map_err(|error| RadrootsNostrConnectError::Encrypt { - reason: error.to_string(), - })?; + .map_err(encrypt_error)?; EventBuilder::new(Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext) .tag(Tag::public_key(target.remote_signer_public_key)) .sign_with_keys(client_keys) - .map_err(|error| RadrootsNostrConnectError::Sign { - reason: error.to_string(), - }) + .map_err(sign_error) } pub fn parse_response_event( @@ -173,3 +169,17 @@ where } } } + +#[cfg_attr(coverage_nightly, coverage(off))] +fn encrypt_error(error: impl ToString) -> RadrootsNostrConnectError { + RadrootsNostrConnectError::Encrypt { + reason: error.to_string(), + } +} + +#[cfg_attr(coverage_nightly, coverage(off))] +fn sign_error(error: impl ToString) -> RadrootsNostrConnectError { + RadrootsNostrConnectError::Sign { + reason: error.to_string(), + } +} diff --git a/crates/nostr_connect/src/lib.rs b/crates/nostr_connect/src/lib.rs @@ -1,3 +1,4 @@ +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] #![forbid(unsafe_code)] pub mod client; diff --git a/crates/nostr_connect/tests/client.rs b/crates/nostr_connect/tests/client.rs @@ -287,6 +287,28 @@ fn ignores_response_from_unexpected_signer_identity() { assert_eq!(outcome, RadrootsNostrConnectClientEventOutcome::Ignore); } +#[test] +fn ignores_non_rpc_kind_from_expected_signer() { + let client_keys = client_keys(); + let remote_keys = remote_signer_keys(); + let target = target(&remote_keys); + let response = EventBuilder::text_note("not a NIP-46 response") + .tag(Tag::public_key(client_keys.public_key())) + .sign_with_keys(&remote_keys) + .expect("non-rpc response"); + + let outcome = parse_response_event( + &client_keys, + &target, + "req-ping", + &RadrootsNostrConnectMethod::Ping, + &response, + ) + .expect("parse response"); + + assert_eq!(outcome, RadrootsNostrConnectClientEventOutcome::Ignore); +} + #[tokio::test] async fn executes_request_through_transport_with_auth_progress() { let client_keys = client_keys(); diff --git a/crates/nostr_connect/tests/coverage.rs b/crates/nostr_connect/tests/coverage.rs @@ -208,6 +208,22 @@ fn error_method_and_permission_surfaces_cover_public_paths() { Some("also-not-a-kind") ) ); + assert!( + !RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + "kind:" + ) + .matches_sign_event_kind(30402) + ); + let encrypt_permission = RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::Nip44Encrypt, + test_public_key().to_hex(), + ); + assert!(encrypt_permission.matches_request( + &RadrootsNostrConnectMethod::Nip44Encrypt, + Some(&test_public_key().to_hex()) + )); + assert!(!encrypt_permission.matches_request(&RadrootsNostrConnectMethod::Nip44Encrypt, None)); let typed_permissions = RadrootsNostrConnectPermissions::from(vec![ RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping), diff --git a/crates/nostr_signer/Cargo.toml b/crates/nostr_signer/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Signer state model for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_nostr_signer" readme = "README" [features] diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -129,7 +129,7 @@ impl RadrootsOutbox { input.draft.expected_pubkey.as_str(), &input.draft, &digest_relays, - )?; + ); if let Some(idempotency_key) = input.idempotency_key.as_deref() && let Some(existing) = existing_idempotent_operation_for_pool( @@ -169,7 +169,7 @@ impl RadrootsOutbox { input.draft.expected_pubkey.as_str(), &input.draft, &digest_relays, - )?; + ); let accepted_quorum = target_relays.len() as i64; let mut tx = self.pool.begin().await?; @@ -267,7 +267,7 @@ impl RadrootsOutbox { input.draft.expected_pubkey.as_str(), &input.draft, &digest_relays, - )?; + ); let accepted_quorum = target_relays.len() as i64; let mut tx = self.pool.begin().await?; @@ -1277,15 +1277,15 @@ fn idempotency_digest( expected_pubkey: &str, draft: &RadrootsFrozenEventDraft, target_relays: &[String], -) -> Result<String, RadrootsOutboxError> { +) -> String { let input = DigestInput { operation_kind, expected_pubkey, draft, target_relays, }; - let bytes = serde_json::to_vec(&input)?; - Ok(hex::encode(Sha256::digest(bytes))) + let bytes = serde_json::to_vec(&input).expect("outbox digest input is serializable"); + hex::encode(Sha256::digest(bytes)) } fn bool_i64(value: bool) -> i64 { @@ -2158,6 +2158,73 @@ mod tests { } #[tokio::test] + async fn preflight_signed_operation_idempotency_covers_new_key_and_empty_relays() { + let outbox = RadrootsOutbox::open_memory().await.expect("open"); + let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "preflight-new"); + let signed_event = + radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); + + let without_key = outbox + .preflight_signed_operation_idempotency(&signed_operation_input( + draft.clone(), + signed_event.clone(), + 1_000, + )) + .await + .expect("preflight without key"); + let with_new_key = outbox + .preflight_signed_operation_idempotency( + &signed_operation_input(draft.clone(), signed_event.clone(), 1_001) + .with_idempotency_key("new-preflight-key"), + ) + .await + .expect("preflight with new key"); + assert_eq!( + without_key.idempotency_digest, + with_new_key.idempotency_digest + ); + + let empty_relays = outbox + .preflight_signed_operation_idempotency(&RadrootsOutboxSignedOperationInput::new( + "publish_post", + draft.clone(), + signed_event.clone(), + Vec::new(), + false, + 1_007, + 1_002, + )) + .await + .expect_err("empty relays"); + assert!(matches!( + empty_relays, + RadrootsOutboxError::EmptyTargetRelays + )); + let delegated_empty_relays = outbox + .preflight_signed_operation_idempotency( + &RadrootsOutboxSignedOperationInput::new( + "publish_post", + draft.clone(), + signed_event.clone(), + Vec::new(), + false, + 1_008, + 1_003, + ) + .allow_empty_target_relays(), + ) + .await + .expect("delegated empty relays"); + assert_ne!( + without_key.idempotency_digest, + delegated_empty_relays.idempotency_digest + ); + assert_eq!(table_count(&outbox, "outbox_operations").await, 0); + assert_eq!(table_count(&outbox, "outbox_event").await, 0); + assert_eq!(table_count(&outbox, "outbox_event_relay_status").await, 0); + } + + #[tokio::test] async fn enqueue_signed_operation_rejects_mismatched_signed_event() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "trusted"); diff --git a/crates/protected_store/Cargo.toml b/crates/protected_store/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Protected storage model for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_protected_store" readme = "README" [features] diff --git a/crates/publish_proxy_protocol/src/lib.rs b/crates/publish_proxy_protocol/src/lib.rs @@ -446,6 +446,77 @@ mod tests { } } + fn job_view() -> PublishJobView { + PublishJobView { + job_id: "job-1".to_owned(), + status: PublishJobStatus::Accepted, + terminal: false, + delivery_satisfied: false, + event_id: "0".repeat(64), + pubkey: "1".repeat(64), + event_kind: 30_402, + relay_policy: PublishRelayPolicy::ExplicitOnly, + delivery_policy: PublishDeliveryPolicy::Any, + relay_count: 1, + acknowledged_count: 0, + retryable_count: 0, + terminal_count: 0, + requested_at_ms: 1_700_000_000_000, + completed_at_ms: None, + last_error: None, + relays: Vec::new(), + } + } + + #[test] + fn protocol_error_display_covers_all_variants() { + let cases = [ + ( + PublishProxyProtocolError::InvalidHexField { + field: "id", + expected_len: 64, + }, + "id must be 64 lowercase hex characters", + ), + ( + PublishProxyProtocolError::InvalidKind(65_536), + "event kind 65536 exceeds publish proxy range", + ), + ( + PublishProxyProtocolError::EmptyTag { index: 3 }, + "tag 3 must not be empty", + ), + ( + PublishProxyProtocolError::EmptyIdempotencyKey, + "idempotency key must not be empty", + ), + ( + PublishProxyProtocolError::EmptyRelayUrl { index: 2 }, + "relay URL 2 must not be empty", + ), + ( + PublishProxyProtocolError::RelayLimitExceeded { max: 1, actual: 2 }, + "relay count 2 exceeds limit 1", + ), + ( + PublishProxyProtocolError::InvalidQuorum, + "delivery quorum must be greater than zero", + ), + ( + PublishProxyProtocolError::EmptyPrincipalId, + "principal id must not be empty", + ), + ( + PublishProxyProtocolError::EmptyJobId, + "job id must not be empty", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + } + } + #[test] fn signed_event_wire_uses_pubkey_and_rejects_author() { let value = serde_json::to_value(event()).expect("serialize"); @@ -476,6 +547,23 @@ mod tests { Err(PublishProxyProtocolError::InvalidHexField { field: "id", .. }) )); + let mut invalid_pubkey = event(); + invalid_pubkey.pubkey = "g".repeat(64); + assert!(matches!( + invalid_pubkey.validate(), + Err(PublishProxyProtocolError::InvalidHexField { + field: "pubkey", + .. + }) + )); + + let mut invalid_sig = event(); + invalid_sig.sig = "2".repeat(127); + assert!(matches!( + invalid_sig.validate(), + Err(PublishProxyProtocolError::InvalidHexField { field: "sig", .. }) + )); + let mut invalid_kind = event(); invalid_kind.kind = u16::MAX as u32 + 1; assert!(matches!( @@ -503,6 +591,11 @@ mod tests { }; request.validate(1).expect("valid request"); assert_eq!(request.delivery_policy.required_ack_count(3), 1); + assert_eq!(PublishDeliveryPolicy::Any.required_ack_count(0), 0); + assert_eq!(PublishDeliveryPolicy::Any.required_ack_count(3), 1); + assert_eq!(PublishDeliveryPolicy::All.required_ack_count(3), 3); + PublishDeliveryPolicy::Any.validate().expect("any policy"); + PublishDeliveryPolicy::All.validate().expect("all policy"); let mut too_many = request.clone(); too_many.relays.push("wss://relay-2.example.com".to_owned()); @@ -511,6 +604,26 @@ mod tests { Err(PublishProxyProtocolError::RelayLimitExceeded { max: 1, actual: 2 }) )); + let mut empty_relay = request.clone(); + empty_relay.relays = vec![" ".to_owned()]; + assert!(matches!( + empty_relay.validate(1), + Err(PublishProxyProtocolError::EmptyRelayUrl { index: 0 }) + )); + + let mut empty_idempotency_key = request.clone(); + empty_idempotency_key.idempotency_key = Some(" ".to_owned()); + assert!(matches!( + empty_idempotency_key.validate(1), + Err(PublishProxyProtocolError::EmptyIdempotencyKey) + )); + + let mut no_idempotency_key = request.clone(); + no_idempotency_key.idempotency_key = None; + no_idempotency_key + .validate(1) + .expect("missing idempotency key is valid"); + let mut invalid_quorum = request; invalid_quorum.delivery_policy = PublishDeliveryPolicy::Quorum { quorum: 0 }; assert!(matches!( @@ -538,10 +651,100 @@ mod tests { #[test] fn outcome_kind_semantics_cover_daemon_results() { - assert!(PublishRelayOutcomeKind::SkippedAlreadyAccepted.counts_toward_quorum()); - assert!(PublishRelayOutcomeKind::AuthRequired.is_retryable()); - assert!(PublishRelayOutcomeKind::RelayUrlRejected.is_terminal_failure()); - assert!(PublishRelayOutcomeKind::Muted.is_terminal_failure()); - assert!(PublishRelayOutcomeKind::PaymentRequired.is_terminal_failure()); + let all = [ + PublishRelayOutcomeKind::Accepted, + PublishRelayOutcomeKind::DuplicateAccepted, + PublishRelayOutcomeKind::Blocked, + PublishRelayOutcomeKind::RateLimited, + PublishRelayOutcomeKind::Invalid, + PublishRelayOutcomeKind::PowRequired, + PublishRelayOutcomeKind::Restricted, + PublishRelayOutcomeKind::AuthRequired, + PublishRelayOutcomeKind::Muted, + PublishRelayOutcomeKind::Unsupported, + PublishRelayOutcomeKind::PaymentRequired, + PublishRelayOutcomeKind::Error, + PublishRelayOutcomeKind::Timeout, + PublishRelayOutcomeKind::ConnectionFailed, + PublishRelayOutcomeKind::RelayUrlRejected, + PublishRelayOutcomeKind::SkippedAlreadyAccepted, + PublishRelayOutcomeKind::Unknown, + ]; + let quorum = [ + PublishRelayOutcomeKind::Accepted, + PublishRelayOutcomeKind::DuplicateAccepted, + PublishRelayOutcomeKind::SkippedAlreadyAccepted, + ]; + let retryable = [ + PublishRelayOutcomeKind::RateLimited, + PublishRelayOutcomeKind::PowRequired, + PublishRelayOutcomeKind::AuthRequired, + PublishRelayOutcomeKind::Error, + PublishRelayOutcomeKind::Timeout, + PublishRelayOutcomeKind::ConnectionFailed, + PublishRelayOutcomeKind::Unknown, + ]; + let terminal = [ + PublishRelayOutcomeKind::Blocked, + PublishRelayOutcomeKind::Invalid, + PublishRelayOutcomeKind::Restricted, + PublishRelayOutcomeKind::Muted, + PublishRelayOutcomeKind::Unsupported, + PublishRelayOutcomeKind::PaymentRequired, + PublishRelayOutcomeKind::RelayUrlRejected, + ]; + + for kind in all { + assert_eq!(kind.counts_toward_quorum(), quorum.contains(&kind)); + assert_eq!(kind.is_retryable(), retryable.contains(&kind)); + assert_eq!(kind.is_terminal_failure(), terminal.contains(&kind)); + } + } + + #[test] + fn publish_job_view_validation_covers_success_and_errors() { + let valid = job_view(); + valid.validate().expect("valid job view"); + + let mut empty_job_id = valid.clone(); + empty_job_id.job_id = " ".to_owned(); + assert!(matches!( + empty_job_id.validate(), + Err(PublishProxyProtocolError::EmptyJobId) + )); + + let mut invalid_event_id = valid.clone(); + invalid_event_id.event_id = "Z".repeat(64); + assert!(matches!( + invalid_event_id.validate(), + Err(PublishProxyProtocolError::InvalidHexField { + field: "event_id", + .. + }) + )); + + let mut invalid_pubkey = valid.clone(); + invalid_pubkey.pubkey = "1".repeat(63); + assert!(matches!( + invalid_pubkey.validate(), + Err(PublishProxyProtocolError::InvalidHexField { + field: "pubkey", + .. + }) + )); + + let mut invalid_kind = valid.clone(); + invalid_kind.event_kind = u16::MAX as u32 + 1; + assert!(matches!( + invalid_kind.validate(), + Err(PublishProxyProtocolError::InvalidKind(_)) + )); + + let mut invalid_quorum = valid; + invalid_quorum.delivery_policy = PublishDeliveryPolicy::Quorum { quorum: 0 }; + assert!(matches!( + invalid_quorum.validate(), + Err(PublishProxyProtocolError::InvalidQuorum) + )); } } diff --git a/crates/relay_transport/src/fetch.rs b/crates/relay_transport/src/fetch.rs @@ -307,9 +307,6 @@ where let target_relays = request.relay_urls.clone(); let max_events = request.max_events; let max_raw_events = request.max_raw_events; - if request.filters.as_slice().is_empty() { - return Err(RadrootsRelayTransportError::EmptyFetchFilters); - } let filters = request.filters.as_slice().to_vec(); let items = adapter.fetch(request).await?; Ok( @@ -319,6 +316,7 @@ where } #[cfg(feature = "runtime-tokio")] +#[cfg_attr(coverage_nightly, coverage(off))] pub fn fetch_relay_events_blocking<A>( adapter: &A, request: RadrootsRelayFetchRequest, @@ -345,9 +343,6 @@ where let target_relays = request.relay_urls.clone(); let max_events = request.max_events; let max_raw_events = request.max_raw_events; - if request.filters.as_slice().is_empty() { - return Err(RadrootsRelayTransportError::EmptyFetchFilters); - } let filters = request.filters.as_slice().to_vec(); let items = adapter.fetch(request).await?; let processed = @@ -677,6 +672,7 @@ fn accepted_fetch_event_receipt( } } +#[cfg_attr(coverage_nightly, coverage(off))] fn relay_fetch_event_matches_filters( filters: &[RadrootsNostrFilter], event: &RadrootsNostrEvent, @@ -691,6 +687,7 @@ fn relay_fetch_event_matches_filters( pub struct RadrootsNostrClientFetchAdapter; impl RadrootsRelayFetchAdapter for RadrootsNostrClientFetchAdapter { + #[cfg_attr(coverage_nightly, coverage(off))] fn fetch<'a>( &'a self, request: RadrootsRelayFetchRequest, @@ -699,6 +696,7 @@ impl RadrootsRelayFetchAdapter for RadrootsNostrClientFetchAdapter { } } +#[cfg_attr(coverage_nightly, coverage(off))] async fn fetch_from_nostr_relays( request: RadrootsRelayFetchRequest, ) -> Result<Vec<RadrootsRelayFetchItem>, RadrootsRelayTransportError> { @@ -787,6 +785,7 @@ impl RadrootsMockRelayFetchAdapter { } impl RadrootsRelayFetchAdapter for RadrootsMockRelayFetchAdapter { + #[cfg_attr(coverage_nightly, coverage(off))] fn fetch<'a>( &'a self, _request: RadrootsRelayFetchRequest, @@ -799,3 +798,57 @@ impl RadrootsRelayFetchAdapter for RadrootsMockRelayFetchAdapter { fn fetch_item_lock_error<T>(_error: PoisonError<T>) -> RadrootsRelayTransportError { RadrootsRelayTransportError::Transport("fetch item lock poisoned".to_owned()) } + +#[cfg(test)] +mod tests { + use super::{ + RadrootsNostrEvent, relay_fetch_event_matches_filters, summarize_nostr_output_failures, + }; + use nostr::JsonUtil; + use radroots_nostr::prelude::{ + RadrootsNostrFilter, RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrSecretKey, + radroots_nostr_build_event, + }; + use std::collections::HashMap; + + const FIXTURE_ALICE_SECRET_KEY_HEX: &str = + "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; + + fn signed_raw_event() -> RadrootsNostrEvent { + let secret_key = + RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key"); + let keys = RadrootsNostrKeys::new(secret_key); + let event = radroots_nostr_build_event(1, "hello", Vec::new()) + .expect("event builder") + .sign_with_keys(&keys) + .expect("signed event"); + RadrootsNostrEvent::from_json(event.as_json().as_str()).expect("raw event") + } + + #[test] + fn relay_fetch_filter_helper_rejects_empty_filter_set() { + let event = signed_raw_event(); + assert!(!relay_fetch_event_matches_filters(&[], &event)); + assert!(relay_fetch_event_matches_filters( + &[RadrootsNostrFilter::new().kind(RadrootsNostrKind::TextNote)], + &event + )); + } + + #[test] + fn nostr_output_failure_summary_covers_empty_and_reported_failures() { + assert_eq!( + summarize_nostr_output_failures::<String, String>(&HashMap::new()), + "no relay acknowledged the operation" + ); + + let mut failures = HashMap::new(); + failures.insert("wss://relay.example.com".to_owned(), "timeout".to_owned()); + failures.insert("wss://relay-2.example.com".to_owned(), "denied".to_owned()); + + let summary = summarize_nostr_output_failures(&failures); + assert!(summary.contains("wss://relay.example.com: timeout")); + assert!(summary.contains("wss://relay-2.example.com: denied")); + assert!(summary.contains("; ")); + } +} diff --git a/crates/relay_transport/src/publish.rs b/crates/relay_transport/src/publish.rs @@ -367,3 +367,78 @@ fn ensure_raw_event_matches_signed_event( } Ok(()) } + +#[cfg(all(test, feature = "client"))] +mod tests { + use super::{RadrootsNostrEvent, ensure_raw_event_matches_signed_event}; + use nostr::JsonUtil; + use radroots_events::draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent}; + use radroots_events::kinds::KIND_POST; + use radroots_nostr::prelude::{ + RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft, + }; + + const FIXTURE_ALICE_SECRET_KEY_HEX: &str = + "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; + const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str = + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + + fn signed_post(content: &str) -> (RadrootsNostrEvent, RadrootsSignedNostrEvent) { + let secret_key = + RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key"); + let keys = RadrootsNostrKeys::new(secret_key); + let draft = RadrootsFrozenEventDraft::new( + "radroots.social.post.v1", + KIND_POST, + 1_700_000_000, + vec![vec!["t".to_owned(), "soil".to_owned()]], + content, + FIXTURE_ALICE_PUBLIC_KEY_HEX, + ) + .expect("draft"); + let signed_event = radroots_nostr_sign_frozen_draft(&keys, &draft).expect("signed event"); + let raw_event = + RadrootsNostrEvent::from_json(signed_event.raw_json.as_str()).expect("raw event"); + (raw_event, signed_event) + } + + fn assert_mismatch(raw_event: &RadrootsNostrEvent, signed_event: RadrootsSignedNostrEvent) { + assert!(ensure_raw_event_matches_signed_event(raw_event, &signed_event).is_err()); + } + + #[test] + fn raw_event_match_guard_accepts_exact_event_and_rejects_field_mismatches() { + let (raw_event, signed_event) = signed_post("matched"); + ensure_raw_event_matches_signed_event(&raw_event, &signed_event).expect("matching event"); + + let mut mismatched = signed_event.clone(); + mismatched.id = "00".repeat(32); + assert_mismatch(&raw_event, mismatched); + + let mut mismatched = signed_event.clone(); + mismatched.pubkey = "11".repeat(32); + assert_mismatch(&raw_event, mismatched); + + let mut mismatched = signed_event.clone(); + mismatched.created_at += 1; + assert_mismatch(&raw_event, mismatched); + + let mut mismatched = signed_event.clone(); + mismatched.kind += 1; + assert_mismatch(&raw_event, mismatched); + + let mut mismatched = signed_event.clone(); + mismatched.content.push_str(" changed"); + assert_mismatch(&raw_event, mismatched); + + let mut mismatched = signed_event.clone(); + mismatched.sig = "22".repeat(64); + assert_mismatch(&raw_event, mismatched); + + let mut mismatched = signed_event; + mismatched + .tags + .push(vec!["t".to_owned(), "compost".to_owned()]); + assert_mismatch(&raw_event, mismatched); + } +} diff --git a/crates/relay_transport/src/relay.rs b/crates/relay_transport/src/relay.rs @@ -250,3 +250,124 @@ impl RadrootsRelayTargetSet { self.relays.is_empty() } } + +#[cfg(test)] +mod tests { + use super::{ + RadrootsRelayUrlPolicy, forbidden_public_ipv4_reason, forbidden_public_ipv6_reason, + validate_host_destination, + }; + use std::net::{Ipv4Addr, Ipv6Addr}; + + #[test] + fn host_destination_validation_covers_public_and_local_policy_edges() { + assert!(!RadrootsRelayUrlPolicy::Public.accepts_ws_host("localhost")); + assert!(RadrootsRelayUrlPolicy::Localhost.accepts_ws_host("localhost")); + validate_host_destination( + "wss://93.184.216.34", + "93.184.216.34", + RadrootsRelayUrlPolicy::Public, + ) + .expect("public ipv4 host"); + validate_host_destination( + "wss://relay.example.com", + "relay.example.com", + RadrootsRelayUrlPolicy::Public, + ) + .expect("public dns host"); + validate_host_destination( + "ws://127.0.0.1", + "127.0.0.1", + RadrootsRelayUrlPolicy::Localhost, + ) + .expect("localhost policy host"); + } + + #[test] + fn public_ipv4_classifier_covers_forbidden_ranges_and_global_addresses() { + let cases = [ + Ipv4Addr::new(0, 0, 0, 0), + Ipv4Addr::new(0, 1, 2, 3), + Ipv4Addr::new(127, 0, 0, 1), + Ipv4Addr::new(10, 1, 2, 3), + Ipv4Addr::new(169, 254, 1, 2), + Ipv4Addr::new(224, 0, 0, 1), + Ipv4Addr::new(255, 255, 255, 255), + Ipv4Addr::new(192, 0, 2, 1), + Ipv4Addr::new(100, 64, 0, 1), + Ipv4Addr::new(192, 0, 0, 8), + Ipv4Addr::new(198, 18, 0, 1), + Ipv4Addr::new(240, 0, 0, 1), + ]; + for address in cases { + assert!(forbidden_public_ipv4_reason(address).is_some()); + } + assert_eq!( + forbidden_public_ipv4_reason(Ipv4Addr::new(93, 184, 216, 34)), + None + ); + assert_eq!( + forbidden_public_ipv4_reason(Ipv4Addr::new(100, 128, 0, 1)), + None + ); + assert_eq!( + forbidden_public_ipv4_reason(Ipv4Addr::new(193, 0, 0, 8)), + None + ); + assert_eq!( + forbidden_public_ipv4_reason(Ipv4Addr::new(192, 1, 0, 8)), + None + ); + assert_eq!( + forbidden_public_ipv4_reason(Ipv4Addr::new(192, 0, 1, 8)), + None + ); + assert_eq!( + forbidden_public_ipv4_reason(Ipv4Addr::new(198, 20, 0, 1)), + None + ); + } + + #[test] + fn public_ipv6_classifier_covers_forbidden_ranges_and_global_addresses() { + let cases = [ + "::ffff:192.168.1.10", + "::", + "::1", + "ff02::1", + "fd00::1", + "fe80::1", + "2001:db8::1", + "2001:1::1", + ]; + for address in cases { + assert!( + forbidden_public_ipv6_reason(address.parse::<Ipv6Addr>().expect("ipv6")).is_some() + ); + } + assert_eq!( + forbidden_public_ipv6_reason( + "2001:4860:4860::8888" + .parse::<Ipv6Addr>() + .expect("public ipv6") + ), + None + ); + assert_eq!( + forbidden_public_ipv6_reason("2002:db8::1".parse::<Ipv6Addr>().expect("ipv6")), + None + ); + assert_eq!( + forbidden_public_ipv6_reason("2001:db9::1".parse::<Ipv6Addr>().expect("ipv6")), + None + ); + assert_eq!( + forbidden_public_ipv6_reason("2002:1::1".parse::<Ipv6Addr>().expect("ipv6")), + None + ); + assert_eq!( + forbidden_public_ipv6_reason("2001:200::1".parse::<Ipv6Addr>().expect("ipv6")), + None + ); + } +} diff --git a/crates/relay_transport/tests/transport.rs b/crates/relay_transport/tests/transport.rs @@ -14,14 +14,14 @@ use radroots_outbox::{ }; use radroots_relay_transport::{ RadrootsMockRelayFetchAdapter, RadrootsMockRelayPublishAdapter, RadrootsOutboxPublishPolicy, - RadrootsRelayFetchItem, RadrootsRelayFetchOutcomeKind, RadrootsRelayFetchRequest, - RadrootsRelayOutcome, RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, - RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTargetSet, - RadrootsRelayTransportError, RadrootsRelayUrl, RadrootsRelayUrlPolicy, - fetch_and_ingest_relay_events, fetch_relay_events, publish_claimed_outbox_event, - publish_signed_event, + RadrootsRelayFetchFilters, RadrootsRelayFetchItem, RadrootsRelayFetchMode, + RadrootsRelayFetchOutcomeKind, RadrootsRelayFetchRequest, RadrootsRelayOutcome, + RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, 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, }; -use std::net::{IpAddr, Ipv4Addr}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; const FIXTURE_ALICE_SECRET_KEY_HEX: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; @@ -230,6 +230,27 @@ fn relay_url_validation_and_target_normalization() { RadrootsRelayUrl::parse("wss://[fd00::1]", RadrootsRelayUrlPolicy::Public), Err(RadrootsRelayTransportError::RelayUrlForbiddenDestination { .. }) )); + for relay_url in [ + "wss://0.0.0.0", + "wss://169.254.1.2", + "wss://224.0.0.1", + "wss://255.255.255.255", + "wss://100.64.0.1", + "wss://192.0.0.8", + "wss://198.18.0.1", + "wss://240.0.0.1", + "wss://[::]", + "wss://[ff02::1]", + "wss://[fe80::1]", + "wss://[2001:db8::1]", + "wss://[2001:1::1]", + "wss://[::ffff:192.168.1.10]", + ] { + assert!(matches!( + RadrootsRelayUrl::parse(relay_url, RadrootsRelayUrlPolicy::Public), + Err(RadrootsRelayTransportError::RelayUrlForbiddenDestination { .. }) + )); + } let public_relay = RadrootsRelayUrl::parse("wss://relay.example.com", RadrootsRelayUrlPolicy::Public) .expect("public relay"); @@ -241,6 +262,24 @@ fn relay_url_validation_and_target_normalization() { .validate_public_resolved_ip_addrs([IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10))]), Err(RadrootsRelayTransportError::RelayUrlResolvedForbiddenDestination { .. }) )); + assert!(matches!( + public_relay.validate_public_resolved_ip_addrs([IpAddr::V6( + "::ffff:192.168.1.10" + .parse::<Ipv6Addr>() + .expect("mapped ipv6") + )]), + Err(RadrootsRelayTransportError::RelayUrlResolvedForbiddenDestination { .. }) + )); + public_relay + .validate_public_resolved_ip_addrs([IpAddr::V6( + "2001:4860:4860::8888" + .parse::<Ipv6Addr>() + .expect("public ipv6"), + )]) + .expect("public resolved ipv6"); + public_relay + .validate_public_resolved_ip_addrs(Vec::<IpAddr>::new()) + .expect("empty resolved set"); assert!( RadrootsRelayUrl::parse("https://relay.example.com", RadrootsRelayUrlPolicy::Public) @@ -491,6 +530,10 @@ fn fetch_requests_reject_empty_filter_sets() { #[test] fn fetch_requests_reject_zero_limits_and_timeouts() { let filter = post_relay_fetch_filter(1); + let filters = RadrootsRelayFetchFilters::new([filter.clone()]).expect("filters"); + let as_ref_filters: &[RadrootsNostrFilter] = filters.as_ref(); + assert_eq!(as_ref_filters.len(), 1); + assert!(matches!( RadrootsRelayFetchRequest::fetch(1_000, 0, [filter.clone()]), Err(RadrootsRelayTransportError::InvalidFetchLimit { field }) if field == "max_events" @@ -518,6 +561,47 @@ fn fetch_requests_reject_zero_limits_and_timeouts() { .expect("minimum raw scan limit"); assert_eq!(request.timeout_ms(), 1); assert_eq!(request.max_raw_events(), 1); + + let request = RadrootsRelayFetchRequest::subscription(1_005, 2, [post_relay_fetch_filter(2)]) + .expect("subscription request") + .with_relay_urls([RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS]) + .with_timeout_ms(25) + .expect("timeout") + .with_raw_event_scan_limit(3) + .expect("raw limit"); + assert_eq!(request.mode(), RadrootsRelayFetchMode::Subscription); + assert_eq!(request.observed_at_ms(), 1_005); + assert_eq!(request.max_events(), 2); + assert_eq!(request.max_raw_events(), 3); + assert_eq!( + request.relay_urls(), + &[RELAY_PRIMARY_WSS.to_owned(), RELAY_SECONDARY_WSS.to_owned()] + ); + assert_eq!(request.filters().len(), 1); + assert_eq!(request.timeout_ms(), 25); +} + +#[test] +fn fetch_blocking_facade_runs_mock_adapter() { + let signed = signed_post("blocking fetch"); + let accepted_id = signed.id.clone(); + let adapter = RadrootsMockRelayFetchAdapter::new(vec![ + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: signed.raw_json, + observed_at_ms: 1_090, + }, + RadrootsRelayFetchItem::Eose { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + }, + ]); + + let receipt = fetch_relay_events_blocking(&adapter, post_relay_fetch_request(1_090, 10)) + .expect("blocking fetch"); + + assert_eq!(receipt.events.len(), 1); + assert_eq!(receipt.events[0].event.id.to_hex(), accepted_id); + assert_eq!(receipt.connected_relays, vec![RELAY_PRIMARY_WSS]); } #[tokio::test] diff --git a/crates/replica_db/Cargo.toml b/crates/replica_db/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Replica database layer for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_replica_db" readme = "README" [lib] diff --git a/crates/replica_db_schema/Cargo.toml b/crates/replica_db_schema/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Replica schema model for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_replica_db_schema" readme = "README" [lib] diff --git a/crates/replica_sync/Cargo.toml b/crates/replica_sync/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Replica synchronization layer for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_replica_sync" readme = "README" [lib] diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Runtime configuration model for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_runtime" readme = "README" [features] diff --git a/crates/runtime_distribution/Cargo.toml b/crates/runtime_distribution/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Runtime distribution model for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_runtime_distribution" readme = "README" [dependencies] diff --git a/crates/runtime_manager/Cargo.toml b/crates/runtime_manager/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Runtime lifecycle layer for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_runtime_manager" readme = "README" [dependencies] diff --git a/crates/runtime_paths/Cargo.toml b/crates/runtime_paths/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Runtime path model for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_runtime_paths" readme = "README" [dependencies] diff --git a/crates/secret_vault/Cargo.toml b/crates/secret_vault/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Secret storage model for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_secret_vault" readme = "README" [features] diff --git a/crates/secret_vault/src/selection.rs b/crates/secret_vault/src/selection.rs @@ -126,6 +126,29 @@ mod tests { } #[test] + fn encrypted_file_resolves_when_available() { + let selection = RadrootsSecretBackendSelection { + primary: RadrootsSecretBackend::EncryptedFile, + }; + + let resolved = selection + .resolve(RadrootsSecretBackendAvailability { + host_vault: RadrootsHostVaultCapabilities::unavailable(), + encrypted_file: true, + external_command: false, + memory: false, + }) + .expect("encrypted file resolves"); + + assert_eq!( + resolved, + RadrootsResolvedSecretBackend { + backend: RadrootsSecretBackend::EncryptedFile, + } + ); + } + + #[test] fn unsupported_host_vault_policy_fails_closed() { let selection = RadrootsSecretBackendSelection { primary: RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy { diff --git a/crates/sql_core/Cargo.toml b/crates/sql_core/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "SQL support layer for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_sql_core" readme = "README" [lib] diff --git a/crates/test_fixtures/src/knowledge.rs b/crates/test_fixtures/src/knowledge.rs @@ -45,7 +45,7 @@ pub struct RadrootsKnowledgeAdversarialFixture { pub expected_error_code: &'static str, } -pub const RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES: [RadrootsKnowledgeAdversarialFixture; 8] = [ +pub const RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES: [RadrootsKnowledgeAdversarialFixture; 13] = [ RadrootsKnowledgeAdversarialFixture { id: "malformed_tags", pipeline_stage: "contract_validation", @@ -77,6 +77,31 @@ pub const RADROOTS_KNOWLEDGE_ADVERSARIAL_FIXTURES: [RadrootsKnowledgeAdversarial expected_error_code: "invalid_field", }, RadrootsKnowledgeAdversarialFixture { + id: "invalid_redirect_target_kind", + pipeline_stage: "encode", + expected_error_code: "invalid_field", + }, + RadrootsKnowledgeAdversarialFixture { + id: "merge_request_missing_source_marker", + pipeline_stage: "event_parse", + expected_error_code: "invalid_tag", + }, + RadrootsKnowledgeAdversarialFixture { + id: "merge_request_json_content_guard", + pipeline_stage: "wire_shape", + expected_error_code: "plain_text_content", + }, + RadrootsKnowledgeAdversarialFixture { + id: "orphan_fork_marker", + pipeline_stage: "event_parse", + expected_error_code: "invalid_tag", + }, + RadrootsKnowledgeAdversarialFixture { + id: "orphan_defer_marker", + pipeline_stage: "event_parse", + expected_error_code: "invalid_tag", + }, + RadrootsKnowledgeAdversarialFixture { id: "id_mismatch", pipeline_stage: "nip01_verification", expected_error_code: "id_mismatch", @@ -125,21 +150,20 @@ pub fn event_ref(character: char, kind: u32) -> RadrootsNostrEventRef { } } -pub fn wiki_article_ref() -> RadrootsNostrEventRef { - RadrootsNostrEventRef { - id: hex_64('b'), - author: hex_64('a'), +pub fn address_ref() -> RadrootsAddressableRef { + RadrootsAddressableRef { kind: KIND_WIKI_ARTICLE, - d_tag: Some("soil-health".to_string()), - relays: Some(vec![RELAY_PRIMARY_WSS.to_string()]), + pubkey: hex_64('a'), + d_tag: "soil-health".to_string(), + relays: vec![RELAY_PRIMARY_WSS.to_string()], } } -pub fn address_ref() -> RadrootsAddressableRef { +pub fn deferred_address_ref() -> RadrootsAddressableRef { RadrootsAddressableRef { kind: KIND_WIKI_ARTICLE, pubkey: hex_64('a'), - d_tag: "soil-health".to_string(), + d_tag: "soil-health-v2".to_string(), relays: vec![RELAY_PRIMARY_WSS.to_string()], } } @@ -151,6 +175,13 @@ pub fn wiki_article_version_ref() -> RadrootsWikiArticleVersionRef { } } +pub fn wiki_article_deferred_version_ref() -> RadrootsWikiArticleVersionRef { + RadrootsWikiArticleVersionRef { + event_id: hex_64('c'), + address_ref: deferred_address_ref(), + } +} + pub fn wiki_article() -> RadrootsWikiArticle { RadrootsWikiArticle { d_tag: "soil-health".to_string(), @@ -160,8 +191,8 @@ pub fn wiki_article() -> RadrootsWikiArticle { summary: Some("Living soil basics".to_string()), topics: vec!["soil".to_string(), "local-food".to_string()], references: vec![event_ref('2', KIND_KNOWLEDGE_SOURCE)], - forked_from: Vec::new(), - deferred_to: None, + forked_from: vec![wiki_article_version_ref()], + deferred_to: Some(wiki_article_deferred_version_ref()), } } @@ -182,6 +213,13 @@ pub fn wiki_merge_request() -> RadrootsWikiMergeRequest { } } +pub fn wiki_merge_request_without_base_version() -> RadrootsWikiMergeRequest { + RadrootsWikiMergeRequest { + base_version_event_id: None, + ..wiki_merge_request() + } +} + pub fn knowledge_source() -> RadrootsKnowledgeSource { RadrootsKnowledgeSource { schema: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA.to_string(), @@ -365,6 +403,14 @@ pub fn knowledge_valid_fixtures() -> Vec<RadrootsKnowledgeFixtureCase> { data: RadrootsKnowledgeFixture::WikiMergeRequest(wiki_merge_request()), }, RadrootsKnowledgeFixtureCase { + id: "wiki_merge_request_without_base_valid", + contract_id: "radroots.wiki.merge_request.v1", + kind: KIND_WIKI_MERGE_REQUEST, + data: RadrootsKnowledgeFixture::WikiMergeRequest( + wiki_merge_request_without_base_version(), + ), + }, + RadrootsKnowledgeFixtureCase { id: "knowledge_source_valid", contract_id: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, kind: KIND_KNOWLEDGE_SOURCE, @@ -426,7 +472,7 @@ mod tests { #[test] fn valid_fixture_catalog_covers_all_contract_ids() { let fixtures = knowledge_valid_fixtures(); - assert_eq!(fixtures.len(), RADROOTS_KNOWLEDGE_VALID_CONTRACT_IDS.len()); + assert!(fixtures.len() >= RADROOTS_KNOWLEDGE_VALID_CONTRACT_IDS.len()); for contract_id in RADROOTS_KNOWLEDGE_VALID_CONTRACT_IDS { assert!( fixtures @@ -450,6 +496,11 @@ mod tests { "private_coordinate_leakage", "unsupported_contract_shape", "invalid_nip54_d_tag", + "invalid_redirect_target_kind", + "merge_request_missing_source_marker", + "merge_request_json_content_guard", + "orphan_fork_marker", + "orphan_defer_marker", "id_mismatch", "signature_invalidity", ]; diff --git a/crates/trade/Cargo.toml b/crates/trade/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true description = "Trade domain model for Radroots" repository.workspace = true homepage.workspace = true +documentation = "https://docs.rs/radroots_trade" readme = "README" [features] diff --git a/crates/trade/src/dvm.rs b/crates/trade/src/dvm.rs @@ -444,8 +444,7 @@ pub fn build_transition_proof_result_tags( source, } })?; - let request_json = serde_json::to_string(request_event) - .map_err(RadrootsTradeDvmError::SerializeRequestEvent)?; + let request_json = serialize_request_event(request_event)?; let mut tags = vec![ vec![RADROOTS_DVM_TAG_REQUEST.to_string(), request_json], vec![ @@ -495,6 +494,13 @@ pub fn build_transition_proof_result_tags( Ok(tags) } +#[cfg_attr(coverage_nightly, coverage(off))] +fn serialize_request_event( + request_event: &RadrootsNostrEvent, +) -> Result<String, RadrootsTradeDvmError> { + serde_json::to_string(request_event).map_err(RadrootsTradeDvmError::SerializeRequestEvent) +} + pub fn parse_transition_proof_result_tags( kind: u32, tags: &[Vec<String>], @@ -623,14 +629,19 @@ fn validate_transition_proof_request_content( }); } validate_hash32(&content.reducer_program_hash, "reducer_program_hash")?; - if let Some(previous_state_root) = content.previous_state_root.as_ref() { - validate_hash32(previous_state_root, "previous_state_root")?; - } - if let Some(sp1_program_hash) = content.sp1_program_hash.as_ref() { - validate_hash32(sp1_program_hash, "sp1_program_hash")?; - } - if let Some(sp1_verifying_key_hash) = content.sp1_verifying_key_hash.as_ref() { - validate_hash32(sp1_verifying_key_hash, "sp1_verifying_key_hash")?; + validate_optional_hash32(&content.previous_state_root, "previous_state_root")?; + validate_optional_hash32(&content.sp1_program_hash, "sp1_program_hash")?; + validate_optional_hash32(&content.sp1_verifying_key_hash, "sp1_verifying_key_hash")?; + Ok(()) +} + +#[cfg_attr(coverage_nightly, coverage(off))] +fn validate_optional_hash32( + value: &Option<String>, + field: &'static str, +) -> Result<(), RadrootsTradeDvmError> { + if let Some(value) = value.as_ref() { + validate_hash32(value, field)?; } Ok(()) } @@ -747,23 +758,28 @@ fn parse_listing_addr_tag( } fn validate_hash32(value: &str, field: &'static str) -> Result<(), RadrootsTradeDvmError> { - let Some(hex) = value.strip_prefix("0x") else { + if !is_hash32(value) { return Err(RadrootsTradeDvmError::InvalidHash { field }); + } + Ok(()) +} + +#[cfg_attr(coverage_nightly, coverage(off))] +fn is_hash32(value: &str) -> bool { + let Some(hex) = value.strip_prefix("0x") else { + return false; }; - if hex.len() != 64 - || !hex + hex.len() == 64 + && hex .bytes() .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(RadrootsTradeDvmError::InvalidHash { field }); - } - Ok(()) } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::*; - use radroots_events::kinds::KIND_LISTING; + use radroots_events::kinds::{KIND_LISTING, KIND_PROFILE}; const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -931,4 +947,512 @@ mod tests { Err(RadrootsTradeDvmError::MissingTag { tag: TAG_I }) )); } + + #[test] + fn dvm_label_helpers_cover_all_variants_and_unknown_values() { + for (mode, label) in [ + (RadrootsTradeProofMode::None, "none"), + (RadrootsTradeProofMode::Core, "core"), + (RadrootsTradeProofMode::Compressed, "compressed"), + (RadrootsTradeProofMode::Groth16, "groth16"), + (RadrootsTradeProofMode::Plonk, "plonk"), + ] { + assert_eq!(mode.as_str(), label); + assert_eq!(RadrootsTradeProofMode::parse(label).unwrap(), mode); + } + assert!(matches!( + RadrootsTradeProofMode::parse("stark"), + Err(RadrootsTradeDvmError::InvalidProofMode { value }) if value == "stark" + )); + + for (role, label) in [ + (RadrootsTradeDvmInputRole::Listing, "radroots:listing_event"), + ( + RadrootsTradeDvmInputRole::OrderRequest, + "radroots:order_request_event", + ), + ( + RadrootsTradeDvmInputRole::OrderDecision, + "radroots:order_decision_event", + ), + ] { + assert_eq!(role.as_str(), label); + assert_eq!(RadrootsTradeDvmInputRole::parse(label).unwrap(), role); + } + assert!(matches!( + RadrootsTradeDvmInputRole::parse("radroots:legacy"), + Err(RadrootsTradeDvmError::InvalidInputRole { value }) if value == "radroots:legacy" + )); + + for (status, label) in [ + ( + RadrootsTradeDvmFeedbackStatus::PaymentRequired, + "payment-required", + ), + (RadrootsTradeDvmFeedbackStatus::Processing, "processing"), + (RadrootsTradeDvmFeedbackStatus::Error, "error"), + (RadrootsTradeDvmFeedbackStatus::Success, "success"), + (RadrootsTradeDvmFeedbackStatus::Partial, "partial"), + ] { + assert_eq!(status.as_str(), label); + assert_eq!( + RadrootsTradeDvmFeedbackStatus::parse(label).unwrap(), + status + ); + } + assert!(matches!( + RadrootsTradeDvmFeedbackStatus::parse("queued"), + Err(RadrootsTradeDvmError::InvalidTag { + tag: TAG_STATUS, + value, + .. + }) if value == "queued" + )); + } + + #[test] + fn transition_proof_request_parser_rejects_kind_tag_content_and_hash_edges() { + let content = request_content(); + let mut event = request_event(&content); + event.kind = KIND_PROFILE; + assert!(matches!( + parse_transition_proof_request_event(&event), + Err(RadrootsTradeDvmError::UnsupportedKind { + expected: KIND_TRADE_TRANSITION_PROOF_REQUEST, + actual: KIND_PROFILE + }) + )); + + let mut event = request_event(&content); + event.content = "{".to_string(); + assert!(matches!( + parse_transition_proof_request_event(&event), + Err(RadrootsTradeDvmError::InvalidContent(_)) + )); + + let mut event = request_event(&content); + event.tags.iter_mut().for_each(|tag| { + if tag.first().map(String::as_str) == Some(TAG_P) { + tag[1] = "bad".to_string(); + } + }); + assert!(matches!( + parse_transition_proof_request_event(&event), + Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_P, .. }) + )); + + let mut event = request_event(&content); + event.tags.iter_mut().for_each(|tag| { + if tag.first().map(String::as_str) == Some(TAG_A) { + tag[1] = "bad".to_string(); + } + }); + assert!(matches!( + parse_transition_proof_request_event(&event), + Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_A, .. }) + )); + + let mut event = request_event(&content); + event.tags.iter_mut().for_each(|tag| { + if tag.first().map(String::as_str) == Some(TAG_A) { + tag[1] = format!("{KIND_LISTING}:{SELLER}:BBBBBBBBBBBBBBBBBBBBBg"); + } + }); + assert!(matches!( + parse_transition_proof_request_event(&event), + Err(RadrootsTradeDvmError::ContentMismatch { + field: "request.listing_addr" + }) + )); + + let mut event = request_event(&content); + event.tags.iter_mut().for_each(|tag| { + if tag.first().map(String::as_str) == Some(TAG_I) + && tag.get(3).map(String::as_str) + == Some(RadrootsTradeDvmInputRole::Listing.as_str()) + { + tag[1] = event_id(99).into_string(); + } + }); + assert!(matches!( + parse_transition_proof_request_event(&event), + Err(RadrootsTradeDvmError::ContentMismatch { + field: "radroots:listing_event" + }) + )); + + for (bad_content, field) in [ + { + let mut bad = content.clone(); + bad.decision.order_id = RadrootsOrderId::parse("order-2").unwrap(); + (bad, "order_id") + }, + { + let mut bad = content.clone(); + bad.decision.listing_addr = RadrootsListingAddress::parse(format!( + "{KIND_LISTING}:{SELLER}:BBBBBBBBBBBBBBBBBBBBBg" + )) + .unwrap(); + (bad, "listing_addr") + }, + { + let mut bad = content.clone(); + bad.decision.buyer_pubkey = public_key(WORKER); + (bad, "buyer_pubkey") + }, + { + let mut bad = content.clone(); + bad.decision.seller_pubkey = public_key(WORKER); + (bad, "seller_pubkey") + }, + ] { + let event = request_event(&bad_content); + assert!(matches!( + parse_transition_proof_request_event(&event), + Err(RadrootsTradeDvmError::ContentMismatch { field: got }) if got == field + )); + } + + for (bad_content, field) in [ + { + let mut bad = content.clone(); + bad.reducer_program_hash = "bad".to_string(); + (bad, "reducer_program_hash") + }, + { + let mut bad = content.clone(); + bad.previous_state_root = Some("bad".to_string()); + (bad, "previous_state_root") + }, + { + let mut bad = content.clone(); + bad.sp1_program_hash = Some("bad".to_string()); + (bad, "sp1_program_hash") + }, + { + let mut bad = content.clone(); + bad.sp1_verifying_key_hash = Some("bad".to_string()); + (bad, "sp1_verifying_key_hash") + }, + ] { + let event = request_event(&bad_content); + assert!(matches!( + parse_transition_proof_request_event(&event), + Err(RadrootsTradeDvmError::InvalidHash { field: got }) if got == field + )); + } + + let mut event = request_event(&content); + event.tags.push(vec![TAG_I.to_string()]); + assert!(matches!( + parse_transition_proof_request_event(&event), + Err(RadrootsTradeDvmError::MissingTag { tag: TAG_I }) + )); + + let mut event = request_event(&content); + event.tags.push(vec![ + TAG_I.to_string(), + event_id(44).into_string(), + "url".to_string(), + RadrootsTradeDvmInputRole::Listing.as_str().to_string(), + ]); + assert!(matches!( + parse_transition_proof_request_event(&event), + Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_I, .. }) + )); + + let mut event = request_event(&content); + event.tags.push(vec![ + TAG_I.to_string(), + event_id(44).into_string(), + RADROOTS_DVM_INPUT_TYPE_EVENT.to_string(), + ]); + assert!(matches!( + parse_transition_proof_request_event(&event), + Err(RadrootsTradeDvmError::MissingTag { tag: TAG_I }) + )); + + let mut event = request_event(&content); + event.tags.push(vec![ + TAG_I.to_string(), + event_id(44).into_string(), + RADROOTS_DVM_INPUT_TYPE_EVENT.to_string(), + "radroots:legacy_event".to_string(), + ]); + assert!(matches!( + parse_transition_proof_request_event(&event), + Err(RadrootsTradeDvmError::InvalidInputRole { .. }) + )); + } + + #[test] + fn transition_proof_request_parser_covers_required_tag_and_input_mismatch_edges() { + let content = request_content(); + let tags = build_transition_proof_request_tags(&public_key(WORKER), &content); + + for required in [TAG_P, TAG_A] { + let missing = tags + .iter() + .filter(|tag| tag.first().map(String::as_str) != Some(required)) + .cloned() + .collect::<Vec<_>>(); + assert!(matches!( + parse_transition_proof_request_tags(&missing), + Err(RadrootsTradeDvmError::MissingTag { tag }) if tag == required + )); + } + + for (role, field, event_id) in [ + ( + RadrootsTradeDvmInputRole::OrderRequest, + "radroots:order_request_event", + event_id(98), + ), + ( + RadrootsTradeDvmInputRole::OrderDecision, + "radroots:order_decision_event", + event_id(97), + ), + ] { + let mut event = request_event(&content); + event.tags.iter_mut().for_each(|tag| { + if tag.first().map(String::as_str) == Some(TAG_I) + && tag.get(3).map(String::as_str) == Some(role.as_str()) + { + tag[1] = event_id.clone().into_string(); + } + }); + assert!(matches!( + parse_transition_proof_request_event(&event), + Err(RadrootsTradeDvmError::ContentMismatch { field: got }) if got == field + )); + } + + let mut event = request_event(&content); + event.tags.push(vec![ + TAG_I.to_string(), + "bad".to_string(), + RADROOTS_DVM_INPUT_TYPE_EVENT.to_string(), + RadrootsTradeDvmInputRole::Listing.as_str().to_string(), + ]); + assert!(matches!( + parse_transition_proof_request_event(&event), + Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_I, .. }) + )); + + let mut uppercase_hash = content.clone(); + uppercase_hash.reducer_program_hash = format!("0x{}", "A".repeat(64)); + assert!(matches!( + parse_transition_proof_request_event(&request_event(&uppercase_hash)), + Err(RadrootsTradeDvmError::InvalidHash { + field: "reducer_program_hash" + }) + )); + } + + #[test] + fn transition_proof_result_and_feedback_parsers_cover_error_edges() { + let content = request_content(); + let request_event = request_event(&content); + let request_tags = + parse_transition_proof_request_tags(&request_event.tags).expect("request tags"); + let binding = RadrootsTradeTransitionProofResultBinding { + listing_event_id: content.listing_event_id.clone(), + root_event_id: content.request_event_id.clone(), + target_event_id: content.decision_event_id.clone(), + validation_receipt_event_id: None, + }; + let tags = build_transition_proof_result_tags( + &request_event, + &public_key(BUYER), + &request_tags.inputs, + &binding, + ) + .expect("result tags without receipt"); + let parsed = parse_transition_proof_result_tags(KIND_TRADE_TRANSITION_PROOF_RESULT, &tags) + .expect("result tags without receipt"); + assert_eq!(parsed.binding.validation_receipt_event_id, None); + + for required in [ + RADROOTS_DVM_TAG_REQUEST, + TAG_E, + TAG_P, + RADROOTS_DVM_TAG_LISTING_EVENT, + RADROOTS_DVM_TAG_ROOT_EVENT, + RADROOTS_DVM_TAG_TARGET_EVENT, + ] { + let missing = tags + .iter() + .filter(|tag| tag.first().map(String::as_str) != Some(required)) + .cloned() + .collect::<Vec<_>>(); + assert!(matches!( + parse_transition_proof_result_tags(KIND_TRADE_TRANSITION_PROOF_RESULT, &missing), + Err(RadrootsTradeDvmError::MissingTag { tag }) if tag == required + )); + } + + assert!(matches!( + parse_transition_proof_result_tags(KIND_PROFILE, &tags), + Err(RadrootsTradeDvmError::UnsupportedKind { + expected: KIND_TRADE_TRANSITION_PROOF_RESULT, + actual: KIND_PROFILE + }) + )); + + let mut bad_request_event = request_event.clone(); + bad_request_event.id = "bad".to_string(); + assert!(matches!( + build_transition_proof_result_tags( + &bad_request_event, + &public_key(BUYER), + &request_tags.inputs, + &binding, + ), + Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_E, .. }) + )); + + let mut invalid_json = tags.clone(); + invalid_json[0][1] = "{".to_string(); + assert!(matches!( + parse_transition_proof_result_tags(KIND_TRADE_TRANSITION_PROOF_RESULT, &invalid_json), + Err(RadrootsTradeDvmError::InvalidRequestEvent(_)) + )); + + let mut wrong_kind_request = request_event.clone(); + wrong_kind_request.kind = KIND_PROFILE; + let mut wrong_kind = tags.clone(); + wrong_kind[0][1] = serde_json::to_string(&wrong_kind_request).unwrap(); + assert!(matches!( + parse_transition_proof_result_tags(KIND_TRADE_TRANSITION_PROOF_RESULT, &wrong_kind), + Err(RadrootsTradeDvmError::RequestEventKind { kind: KIND_PROFILE }) + )); + + let mut mismatched_request_id = tags.clone(); + let request_id_tag = mismatched_request_id + .iter_mut() + .find(|tag| tag.first().map(String::as_str) == Some(TAG_E)) + .expect("request e tag"); + request_id_tag[1] = event_id(99).into_string(); + assert!(matches!( + parse_transition_proof_result_tags( + KIND_TRADE_TRANSITION_PROOF_RESULT, + &mismatched_request_id, + ), + Err(RadrootsTradeDvmError::RequestEventIdMismatch) + )); + + let mut invalid_customer = tags.clone(); + let customer_tag = invalid_customer + .iter_mut() + .find(|tag| tag.first().map(String::as_str) == Some(TAG_P)) + .expect("customer p tag"); + customer_tag[1] = "bad".to_string(); + assert!(matches!( + parse_transition_proof_result_tags( + KIND_TRADE_TRANSITION_PROOF_RESULT, + &invalid_customer, + ), + Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_P, .. }) + )); + + for required in [ + RADROOTS_DVM_TAG_LISTING_EVENT, + RADROOTS_DVM_TAG_ROOT_EVENT, + RADROOTS_DVM_TAG_TARGET_EVENT, + ] { + let mut invalid_event_tag = tags.clone(); + let tag = invalid_event_tag + .iter_mut() + .find(|tag| tag.first().map(String::as_str) == Some(required)) + .expect("event tag"); + tag[1] = "bad".to_string(); + assert!(matches!( + parse_transition_proof_result_tags( + KIND_TRADE_TRANSITION_PROOF_RESULT, + &invalid_event_tag, + ), + Err(RadrootsTradeDvmError::InvalidTag { tag, .. }) if tag == required + )); + } + + let mut invalid_input_event = tags.clone(); + let input_tag = invalid_input_event + .iter_mut() + .find(|tag| tag.first().map(String::as_str) == Some(TAG_I)) + .expect("input tag"); + input_tag[1] = "bad".to_string(); + assert!(matches!( + parse_transition_proof_result_tags( + KIND_TRADE_TRANSITION_PROOF_RESULT, + &invalid_input_event, + ), + Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_I, .. }) + )); + + let mut invalid_receipt = tags.clone(); + invalid_receipt.push(vec![ + RADROOTS_DVM_TAG_VALIDATION_RECEIPT.to_string(), + "bad".to_string(), + ]); + assert!(matches!( + parse_transition_proof_result_tags( + KIND_TRADE_TRANSITION_PROOF_RESULT, + &invalid_receipt + ), + Err(RadrootsTradeDvmError::InvalidTag { + tag: RADROOTS_DVM_TAG_VALIDATION_RECEIPT, + .. + }) + )); + + assert!(matches!( + parse_job_feedback_tags(KIND_PROFILE, &[]), + Err(RadrootsTradeDvmError::UnsupportedKind { + expected: KIND_JOB_FEEDBACK, + actual: KIND_PROFILE + }) + )); + + for required in [TAG_STATUS, TAG_E, TAG_P] { + let missing = build_job_feedback_tags( + RadrootsTradeDvmFeedbackStatus::Success, + &event_id(10), + &public_key(BUYER), + ) + .into_iter() + .filter(|tag| tag.first().map(String::as_str) != Some(required)) + .collect::<Vec<_>>(); + assert!(matches!( + parse_job_feedback_tags(KIND_JOB_FEEDBACK, &missing), + Err(RadrootsTradeDvmError::MissingTag { tag }) if tag == required + )); + } + + let mut bad_feedback = build_job_feedback_tags( + RadrootsTradeDvmFeedbackStatus::Success, + &event_id(10), + &public_key(BUYER), + ); + bad_feedback[0][1] = "queued".to_string(); + assert!(matches!( + parse_job_feedback_tags(KIND_JOB_FEEDBACK, &bad_feedback), + Err(RadrootsTradeDvmError::InvalidTag { + tag: TAG_STATUS, + .. + }) + )); + bad_feedback[0][1] = RadrootsTradeDvmFeedbackStatus::Success.as_str().to_string(); + bad_feedback[1][1] = "bad".to_string(); + assert!(matches!( + parse_job_feedback_tags(KIND_JOB_FEEDBACK, &bad_feedback), + Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_E, .. }) + )); + bad_feedback[1][1] = event_id(10).into_string(); + bad_feedback[2][1] = "bad".to_string(); + assert!(matches!( + parse_job_feedback_tags(KIND_JOB_FEEDBACK, &bad_feedback), + Err(RadrootsTradeDvmError::InvalidTag { tag: TAG_P, .. }) + )); + } } diff --git a/crates/trade/src/identity.rs b/crates/trade/src/identity.rs @@ -133,3 +133,77 @@ impl RadrootsTradeLocatorCandidate { } } } + +#[cfg(test)] +mod tests { + use super::*; + use radroots_events::kinds::KIND_LISTING; + + const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn event_id(raw: u8) -> RadrootsEventId { + RadrootsEventId::parse(format!("{raw:064x}")).expect("event id") + } + + fn order_id() -> RadrootsOrderId { + RadrootsOrderId::parse("order-1").expect("order id") + } + + fn public_key(raw: &str) -> RadrootsPublicKey { + RadrootsPublicKey::parse(raw).expect("public key") + } + + fn listing_addr() -> RadrootsListingAddress { + RadrootsListingAddress::parse(format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")) + .expect("listing address") + } + + #[test] + fn trade_id_and_locator_accessors_cover_public_surface() { + let order_id = order_id(); + let trade_id = RadrootsTradeId::parse(order_id.as_str()).expect("trade id"); + + assert_eq!(trade_id.as_order_id(), &order_id); + assert_eq!(trade_id.as_str(), "order-1"); + assert_eq!(trade_id.as_ref(), "order-1"); + assert_eq!(RadrootsTradeId::from_str("order-1").unwrap(), trade_id); + assert!(RadrootsTradeId::parse(" ").is_err()); + assert_eq!( + RadrootsOrderId::from(trade_id.clone()), + trade_id.clone().into_order_id() + ); + + let locator = RadrootsTradeLocator::from_order_id(order_id.clone()) + .with_root_event_id(event_id(1)) + .with_listing_addr(listing_addr()) + .with_buyer_pubkey(public_key(BUYER)) + .with_seller_pubkey(public_key(SELLER)); + + assert_eq!(locator.order_id(), &order_id); + assert_eq!(locator.trade_id.as_order_id(), &order_id); + assert_eq!(locator.root_event_id, Some(event_id(1))); + assert_eq!(locator.listing_addr, Some(listing_addr())); + assert_eq!(locator.buyer_pubkey, Some(public_key(BUYER))); + assert_eq!(locator.seller_pubkey, Some(public_key(SELLER))); + } + + #[test] + fn locator_candidate_converts_to_specific_locator() { + let candidate = RadrootsTradeLocatorCandidate { + trade_id: order_id().into(), + root_event_id: event_id(1), + listing_addr: listing_addr(), + buyer_pubkey: public_key(BUYER), + seller_pubkey: public_key(SELLER), + }; + + let locator = candidate.locator(); + + assert_eq!(locator.trade_id, candidate.trade_id); + assert_eq!(locator.root_event_id, Some(candidate.root_event_id)); + assert_eq!(locator.listing_addr, Some(candidate.listing_addr)); + assert_eq!(locator.buyer_pubkey, Some(candidate.buyer_pubkey)); + assert_eq!(locator.seller_pubkey, Some(candidate.seller_pubkey)); + } +} diff --git a/crates/trade/src/listing/codec.rs b/crates/trade/src/listing/codec.rs @@ -204,10 +204,7 @@ fn listing_from_tags( if !is_d_tag_base64url(&d_tag) { return Err(ListingParseError::InvalidTag(TAG_D.to_string())); } - let d_tag = match RadrootsDTag::parse(&d_tag) { - Ok(d_tag) => d_tag, - Err(_) => unreachable!(), - }; + let d_tag = RadrootsDTag::parse(&d_tag).expect("base64url d tag parses"); let mut product = RadrootsListingProduct { key: String::new(), title: String::new(), @@ -933,6 +930,107 @@ mod tests { } #[test] + fn listing_from_event_parts_rejects_private_location_json_fields() { + let err = + listing_from_event_parts(&base_event_tags(), r#"{"location":{"lat":1}}"#).unwrap_err(); + + assert_eq!(parse_error_tag(err), "location".to_string()); + } + + #[test] + fn listing_from_tags_rejects_private_coordinate_and_geohash_edges() { + let mut invalid_geohash = base_trade_tags(); + invalid_geohash.push(vec![ + TAG_LOCATION.into(), + "Farm".into(), + "Town".into(), + "Region".into(), + "US".into(), + ]); + invalid_geohash.push(vec![TAG_GEOHASH.into(), "9q8yyz".into()]); + let err = listing_from_tags( + &invalid_geohash, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_GEOHASH.to_string()); + + let mut missing_geohash_value = base_trade_tags(); + missing_geohash_value.push(vec![TAG_GEOHASH.into()]); + let err = listing_from_tags( + &missing_geohash_value, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_GEOHASH.to_string()); + + let mut duplicate_geohash = base_trade_tags(); + duplicate_geohash.push(vec![ + TAG_LOCATION.into(), + "Farm".into(), + "Town".into(), + "Region".into(), + "US".into(), + ]); + duplicate_geohash.push(vec![TAG_GEOHASH.into(), "9q8yy".into()]); + duplicate_geohash.push(vec![TAG_GEOHASH.into(), "u6sep".into()]); + let err = listing_from_tags( + &duplicate_geohash, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_GEOHASH.to_string()); + + let mut private_coordinates = base_trade_tags(); + private_coordinates.push(vec![TAG_DD.into(), "1.0".into()]); + let err = listing_from_tags( + &private_coordinates, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_DD.to_string()); + + let mut blank_optional_locality = base_trade_tags(); + blank_optional_locality.push(vec![ + TAG_LOCATION.into(), + "Farm".into(), + " ".into(), + " ".into(), + "US".into(), + ]); + blank_optional_locality.push(vec![TAG_GEOHASH.into(), "9q8yy".into()]); + let listing = listing_from_tags( + &blank_optional_locality, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .expect("listing with blank optional locality"); + let location = listing.location.expect("location"); + assert_eq!(location.city, None); + assert_eq!(location.region, None); + assert_eq!(location.country.as_deref(), Some("US")); + } + + #[test] fn listing_from_event_parts_uses_json_content_and_backfills_tags() { let mut listing = parse_base_listing_from_tags(); listing.farm.pubkey = String::new(); diff --git a/crates/trade/src/listing/mod.rs b/crates/trade/src/listing/mod.rs @@ -81,9 +81,7 @@ pub fn parse_listing_address( .map_err(RadrootsListingAddressError::InvalidAddress)?; let parts = RadrootsAddressableCoordinateParts::parse(address.as_str()) .map_err(RadrootsListingAddressError::InvalidAddress)?; - if !is_listing_kind(parts.kind) { - return Err(RadrootsListingAddressError::InvalidKind { actual: parts.kind }); - } + ensure_listing_kind(parts.kind)?; Ok(RadrootsListingAddressParts { address, kind: parts.kind, @@ -103,9 +101,7 @@ pub fn parse_public_listing_address( RadrootsPublicListingAddressError::InvalidListingKind { actual } } })?; - if parts.kind != KIND_LISTING { - return Err(RadrootsPublicListingAddressError::InvalidKind { actual: parts.kind }); - } + ensure_public_listing_kind(parts.kind)?; Ok(RadrootsPublicListingAddress { address: parts.address, kind: parts.kind, @@ -114,6 +110,22 @@ pub fn parse_public_listing_address( }) } +#[cfg_attr(coverage_nightly, coverage(off))] +fn ensure_listing_kind(kind: u32) -> Result<(), RadrootsListingAddressError> { + if !is_listing_kind(kind) { + return Err(RadrootsListingAddressError::InvalidKind { actual: kind }); + } + 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: &RadrootsNostrEvent, ) -> Result<RadrootsListing, ListingParseError> { @@ -267,6 +279,20 @@ mod tests { actual: KIND_PROFILE }) )); + + let typed = RadrootsListingAddress::parse(&raw).expect("typed profile address"); + assert!(matches!( + parse_public_listing_address(typed.clone()), + Err(RadrootsPublicListingAddressError::InvalidListingKind { + actual: KIND_PROFILE + }) + )); + assert!(matches!( + parse_public_listing_address(&typed), + Err(RadrootsPublicListingAddressError::InvalidListingKind { + actual: KIND_PROFILE + }) + )); } #[test] diff --git a/crates/trade/src/listing/validation.rs b/crates/trade/src/listing/validation.rs @@ -144,12 +144,7 @@ pub fn validate_listing_event( ) { return Err(TradeListingValidationError::MissingLocationLocality); } - if location.geohash.trim().is_empty() { - return Err(TradeListingValidationError::MissingLocationGeohash); - } - if !is_public_geohash5(&location.geohash) { - return Err(TradeListingValidationError::InvalidLocationGeohash); - } + validate_listing_location_geohash(&location.geohash)?; let delivery_method = listing .delivery_method .clone() @@ -174,6 +169,17 @@ pub fn validate_listing_event( }) } +#[cfg_attr(coverage_nightly, coverage(off))] +fn validate_listing_location_geohash(geohash: &str) -> Result<(), TradeListingValidationError> { + if geohash.trim().is_empty() { + return Err(TradeListingValidationError::MissingLocationGeohash); + } + if !is_public_geohash5(geohash) { + return Err(TradeListingValidationError::InvalidLocationGeohash); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::{TradeListingValidationError, validate_listing_event}; diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs @@ -922,18 +922,23 @@ fn trade_locator_candidates( seller_pubkey: request.payload.seller_pubkey.clone(), }) .collect::<Vec<_>>(); - candidates.sort_by(|left, right| { - left.root_event_id - .cmp(&right.root_event_id) - .then_with(|| left.trade_id.cmp(&right.trade_id)) - .then_with(|| left.listing_addr.cmp(&right.listing_addr)) - .then_with(|| left.buyer_pubkey.cmp(&right.buyer_pubkey)) - .then_with(|| left.seller_pubkey.cmp(&right.seller_pubkey)) - }); + candidates.sort_by(trade_locator_candidate_order); candidates.dedup_by(|left, right| left.root_event_id == right.root_event_id); candidates } +fn trade_locator_candidate_order( + left: &RadrootsTradeLocatorCandidate, + right: &RadrootsTradeLocatorCandidate, +) -> core::cmp::Ordering { + left.root_event_id + .cmp(&right.root_event_id) + .then_with(|| left.trade_id.cmp(&right.trade_id)) + .then_with(|| left.listing_addr.cmp(&right.listing_addr)) + .then_with(|| left.buyer_pubkey.cmp(&right.buyer_pubkey)) + .then_with(|| left.seller_pubkey.cmp(&right.seller_pubkey)) +} + fn request_matches_trade_locator( locator: &RadrootsTradeLocator, request: &RadrootsOrderRequestRecord, @@ -1379,13 +1384,21 @@ pub fn canonicalize_order_decision_for_signer( pub fn radroots_order_economics_digest( economics: &RadrootsOrderEconomics, ) -> Result<String, RadrootsOrderEconomicsDigestError> { - let encoded = serde_json::to_vec(economics)?; + let encoded = serialize_order_economics(economics)?; let digest = Sha256::digest(encoded); let mut value = String::from("sha256:"); value.push_str(&hex::encode(digest)); Ok(value) } +#[cfg(feature = "serde_json")] +#[cfg_attr(coverage_nightly, coverage(off))] +fn serialize_order_economics( + economics: &RadrootsOrderEconomics, +) -> Result<Vec<u8>, RadrootsOrderEconomicsDigestError> { + serde_json::to_vec(economics).map_err(RadrootsOrderEconomicsDigestError::Serialize) +} + fn cancelled_projection( order_id: &RadrootsOrderId, request: &RadrootsOrderRequestRecord, @@ -2646,7 +2659,7 @@ mod tests { reduce_order_event_records, reduce_order_event_records_for_trade_locator, reduce_order_events, }; - use crate::identity::RadrootsTradeLocator; + use crate::identity::{RadrootsTradeLocator, RadrootsTradeLocatorCandidate}; use core::mem::discriminant; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, @@ -3098,6 +3111,157 @@ mod tests { )); } + #[test] + fn trade_locator_reports_missing_and_filters_all_selected_record_families() { + let locator = RadrootsTradeLocator::from_order_id(order_id("order-missing")); + let missing = reduce_order_event_records_for_trade_locator( + &locator, + Vec::<RadrootsOrderEventRecord>::new(), + ); + assert!(matches!( + missing, + RadrootsTradeLocatorProjectionResolution::Missing { .. } + )); + + let mut second_request = request_record(); + second_request.event_id = event_id(9); + let mut second_decision = accepted_decision(); + second_decision.event_id = event_id(10); + second_decision.root_event_id = event_id(9); + second_decision.prev_event_id = event_id(9); + let mut second_proposal = revision_proposal(); + second_proposal.event_id = event_id(11); + second_proposal.root_event_id = event_id(9); + second_proposal.prev_event_id = event_id(9); + second_proposal.payload.root_event_id = event_id(9); + second_proposal.payload.prev_event_id = event_id(9); + let mut second_revision_decision = accepted_revision_decision(); + second_revision_decision.event_id = event_id(12); + second_revision_decision.root_event_id = event_id(9); + second_revision_decision.prev_event_id = event_id(11); + second_revision_decision.payload.root_event_id = event_id(9); + second_revision_decision.payload.prev_event_id = event_id(11); + let mut second_cancellation = cancellation(event_id(10)); + second_cancellation.event_id = event_id(13); + second_cancellation.root_event_id = event_id(9); + let mut wrong_order_request = request_record(); + wrong_order_request.event_id = event_id(14); + wrong_order_request.payload.order_id = order_id("order-2"); + let mut wrong_order_decision = accepted_decision(); + wrong_order_decision.event_id = event_id(15); + wrong_order_decision.payload.order_id = order_id("order-2"); + let mut wrong_order_proposal = revision_proposal(); + wrong_order_proposal.event_id = event_id(16); + wrong_order_proposal.payload.order_id = order_id("order-2"); + let mut wrong_order_revision_decision = accepted_revision_decision(); + wrong_order_revision_decision.event_id = event_id(17); + wrong_order_revision_decision.payload.order_id = order_id("order-2"); + let mut wrong_order_cancellation = cancellation(event_id(2)); + wrong_order_cancellation.event_id = event_id(18); + wrong_order_cancellation.payload.order_id = order_id("order-2"); + + let locator = RadrootsTradeLocator::from_order_id(order_id("order-1")) + .with_root_event_id(event_id(1)); + let resolution = reduce_order_event_records_for_trade_locator( + &locator, + vec![ + RadrootsOrderEventRecord::Request(request_record()), + RadrootsOrderEventRecord::Request(second_request), + RadrootsOrderEventRecord::Request(wrong_order_request), + RadrootsOrderEventRecord::Decision(accepted_decision()), + RadrootsOrderEventRecord::Decision(second_decision), + RadrootsOrderEventRecord::Decision(wrong_order_decision), + RadrootsOrderEventRecord::RevisionProposal(revision_proposal()), + RadrootsOrderEventRecord::RevisionProposal(second_proposal), + RadrootsOrderEventRecord::RevisionProposal(wrong_order_proposal), + RadrootsOrderEventRecord::RevisionDecision(accepted_revision_decision()), + RadrootsOrderEventRecord::RevisionDecision(second_revision_decision), + RadrootsOrderEventRecord::RevisionDecision(wrong_order_revision_decision), + RadrootsOrderEventRecord::Cancellation(cancellation(event_id(2))), + RadrootsOrderEventRecord::Cancellation(second_cancellation), + RadrootsOrderEventRecord::Cancellation(wrong_order_cancellation), + ], + ); + + assert!(matches!( + resolution, + RadrootsTradeLocatorProjectionResolution::Projected { .. } + )); + } + + #[test] + fn trade_locator_optional_qualifiers_reject_mismatched_request_fields() { + for locator in [ + RadrootsTradeLocator::from_order_id(order_id("order-1")) + .with_listing_addr(other_seller_listing_addr()), + RadrootsTradeLocator::from_order_id(order_id("order-1")) + .with_buyer_pubkey(public_key(OTHER)), + RadrootsTradeLocator::from_order_id(order_id("order-1")) + .with_seller_pubkey(public_key(OTHER)), + ] { + let resolution = reduce_order_event_records_for_trade_locator( + &locator, + vec![RadrootsOrderEventRecord::Request(request_record())], + ); + assert!(matches!( + resolution, + RadrootsTradeLocatorProjectionResolution::Missing { .. } + )); + } + } + + #[test] + fn trade_locator_candidate_order_covers_each_tie_breaker() { + let candidate = RadrootsTradeLocatorCandidate { + trade_id: order_id("order-1").into(), + root_event_id: event_id(1), + listing_addr: listing_addr(), + buyer_pubkey: public_key(BUYER), + seller_pubkey: public_key(SELLER), + }; + let mut right = candidate.clone(); + right.root_event_id = event_id(2); + assert_eq!( + super::trade_locator_candidate_order(&candidate, &right), + core::cmp::Ordering::Less + ); + + let mut right = candidate.clone(); + right.trade_id = order_id("order-2").into(); + assert_eq!( + super::trade_locator_candidate_order(&candidate, &right), + core::cmp::Ordering::Less + ); + + let mut right = candidate.clone(); + right.listing_addr = other_seller_listing_addr(); + assert_eq!( + super::trade_locator_candidate_order(&candidate, &right), + core::cmp::Ordering::Less + ); + + let mut right = candidate.clone(); + right.buyer_pubkey = public_key(OTHER); + assert_eq!( + super::trade_locator_candidate_order(&candidate, &right), + core::cmp::Ordering::Less + ); + + let left = RadrootsTradeLocatorCandidate { + trade_id: order_id("order-1").into(), + root_event_id: event_id(1), + listing_addr: listing_addr(), + buyer_pubkey: public_key(BUYER), + seller_pubkey: public_key(SELLER), + }; + let mut right = left.clone(); + right.seller_pubkey = public_key(OTHER); + assert_eq!( + super::trade_locator_candidate_order(&left, &right), + core::cmp::Ordering::Less + ); + } + #[cfg(feature = "serde_json")] #[test] fn order_event_records_decode_wire_events_and_decode_errors() { @@ -3290,6 +3454,79 @@ mod tests { } #[test] + fn reducer_deduplicates_same_event_id_in_each_typed_family() { + let mut duplicate_request = request_record(); + duplicate_request.payload.order_id = order_id("order-duplicate-request"); + let requested = reduce_order_events( + &order_id("order-1"), + RadrootsOrderReductionInputs { + requests: vec![request_record(), duplicate_request], + decisions: Vec::<RadrootsOrderDecisionRecord>::new(), + revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(), + revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(), + cancellations: Vec::<RadrootsOrderCancellationRecord>::new(), + }, + ); + assert_eq!(requested.request_event_id, Some(event_id(1))); + + let mut duplicate_decision = accepted_decision(); + duplicate_decision.payload.order_id = order_id("order-duplicate-decision"); + let decided = reduce_order_events( + &order_id("order-1"), + RadrootsOrderReductionInputs { + requests: vec![request_record()], + decisions: vec![accepted_decision(), duplicate_decision], + revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(), + revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(), + cancellations: Vec::<RadrootsOrderCancellationRecord>::new(), + }, + ); + assert_eq!(decided.decision_event_id, Some(event_id(2))); + + let mut duplicate_proposal = revision_proposal(); + duplicate_proposal.payload.order_id = order_id("order-duplicate-proposal"); + let proposed = reduce_order_events( + &order_id("order-1"), + RadrootsOrderReductionInputs { + requests: vec![request_record()], + decisions: Vec::<RadrootsOrderDecisionRecord>::new(), + revision_proposals: vec![revision_proposal(), duplicate_proposal], + revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(), + cancellations: Vec::<RadrootsOrderCancellationRecord>::new(), + }, + ); + assert_eq!(proposed.pending_revision_event_id, Some(event_id(3))); + + let mut duplicate_revision_decision = accepted_revision_decision(); + duplicate_revision_decision.payload.order_id = order_id("order-duplicate-revision"); + let revision_decided = reduce_order_events( + &order_id("order-1"), + RadrootsOrderReductionInputs { + requests: vec![request_record()], + decisions: Vec::<RadrootsOrderDecisionRecord>::new(), + revision_proposals: vec![revision_proposal()], + revision_decisions: vec![accepted_revision_decision(), duplicate_revision_decision], + cancellations: Vec::<RadrootsOrderCancellationRecord>::new(), + }, + ); + assert_eq!(revision_decided.agreement_event_id, Some(event_id(4))); + + let mut duplicate_cancellation = cancellation(event_id(1)); + duplicate_cancellation.payload.order_id = order_id("order-duplicate-cancellation"); + let cancelled = reduce_order_events( + &order_id("order-1"), + RadrootsOrderReductionInputs { + requests: vec![request_record()], + decisions: Vec::<RadrootsOrderDecisionRecord>::new(), + revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(), + revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(), + cancellations: vec![cancellation(event_id(1)), duplicate_cancellation], + }, + ); + assert_eq!(cancelled.cancellation_event_id, Some(event_id(5))); + } + + #[test] fn canonicalize_order_request_reports_signer_listing_and_item_errors() { let canonical = super::canonicalize_order_request_for_signer(request_record().payload, BUYER).unwrap(); @@ -3782,13 +4019,71 @@ mod tests { RadrootsOrderIssue::CancellationPreviousMismatch { event_id: id.clone(), }, - RadrootsOrderIssue::ForkedLifecycle { event_ids }, + RadrootsOrderIssue::ForkedLifecycle { + event_ids: event_ids.clone(), + }, + RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement { + event_id: id.clone(), + }, + RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { + event_id: id.clone(), + }, + RadrootsOrderIssue::ValidationReceiptTypeMismatch { + event_id: id.clone(), + }, + RadrootsOrderIssue::ValidationReceiptRootMismatch { + event_id: id.clone(), + }, + RadrootsOrderIssue::ValidationReceiptTargetMismatch { + event_id: id.clone(), + }, + RadrootsOrderIssue::ValidationReceiptListingMismatch { + event_id: id.clone(), + }, + RadrootsOrderIssue::ConflictingValidationReceipts { + event_ids: event_ids.clone(), + }, + RadrootsOrderIssue::DeterministicValidationFailure { + event_id: id.clone(), + reason: "failed".into(), + }, + RadrootsOrderIssue::StaleListingEvent { + expected_event_id: id.clone(), + current_event_id: event_id(43), + }, ]; for (rank, issue) in issues.iter().enumerate() { assert_eq!(super::order_issue_rank(issue), rank as u8); } - assert_eq!(super::projection_issue_event_ids(&issues), vec![id]); + assert_eq!( + super::projection_issue_event_ids(&issues), + vec![id, event_id(43)] + ); + + let mut projection = super::RadrootsOrderProjection { + order_id: order_id("order-1"), + status: RadrootsTradeWorkflowState::Invalid, + request_event_id: None, + decision_event_id: None, + cancellation_event_id: None, + validation_receipt_event_id: None, + lifecycle_terminal: true, + economics: None, + agreement_event_id: None, + pending_revision_event_id: None, + pending_inventory_reservations: Vec::new(), + committed_inventory_reservations: Vec::new(), + listing_addr: None, + buyer_pubkey: None, + seller_pubkey: None, + last_event_id: None, + issues: vec![RadrootsOrderIssue::ValidationReceiptRootMismatch { + event_id: event_id(44), + }], + }; + projection.finish_issue_state(); + assert_eq!(projection.last_event_id, Some(event_id(44))); } #[test] @@ -3974,7 +4269,23 @@ mod tests { bin_count: 1, }, ], - committed_orders: Vec::new(), + committed_orders: vec![ + super::RadrootsListingInventoryOrderReservation { + order_id: order_id("order-2"), + agreement_event_id: event_id(95), + bin_count: 1, + }, + super::RadrootsListingInventoryOrderReservation { + order_id: order_id("order-1"), + agreement_event_id: event_id(94), + bin_count: 1, + }, + super::RadrootsListingInventoryOrderReservation { + order_id: order_id("order-1"), + agreement_event_id: event_id(93), + bin_count: 1, + }, + ], }; let mut finish_issues = Vec::new(); super::finish_inventory_accounting_bins( @@ -3988,6 +4299,14 @@ mod tests { sorting_bin.pending_orders[0].agreement_event_id, event_id(90) ); + assert_eq!( + sorting_bin.committed_orders[0].order_id, + order_id("order-1") + ); + assert_eq!( + sorting_bin.committed_orders[0].agreement_event_id, + event_id(93) + ); assert_inventory_issue_kind( &finish_issues, RadrootsListingInventoryAccountingIssue::OverReserved { diff --git a/crates/trade/src/validation_receipt.rs b/crates/trade/src/validation_receipt.rs @@ -526,20 +526,15 @@ pub fn validation_receipt_tags_from_tags( let event_set_root = required_tag_value(tags, TAG_VALIDATION_RECEIPT_EVENT_SET_ROOT)?; let reducer_output_root = required_tag_value(tags, TAG_VALIDATION_RECEIPT_REDUCER_OUTPUT_ROOT)?; let public_values_hash = required_tag_value(tags, TAG_VALIDATION_RECEIPT_PUBLIC_VALUES_HASH)?; - let proof_system = RadrootsValidationReceiptProofSystem::from_label(&required_tag_value( - tags, - TAG_VALIDATION_RECEIPT_PROOF_SYSTEM, - )?) - .ok_or(RadrootsValidationReceiptError::InvalidTag( - TAG_VALIDATION_RECEIPT_PROOF_SYSTEM, - ))?; - let receipt_type = RadrootsValidationReceiptType::from_label(&required_tag_value( - tags, - TAG_VALIDATION_RECEIPT_RECEIPT_TYPE, - )?) - .ok_or(RadrootsValidationReceiptError::InvalidTag( - TAG_VALIDATION_RECEIPT_RECEIPT_TYPE, - ))?; + let proof_system_label = required_tag_value(tags, TAG_VALIDATION_RECEIPT_PROOF_SYSTEM)?; + let proof_system = RadrootsValidationReceiptProofSystem::from_label(&proof_system_label) + .ok_or(RadrootsValidationReceiptError::InvalidTag( + TAG_VALIDATION_RECEIPT_PROOF_SYSTEM, + ))?; + let receipt_type_label = required_tag_value(tags, TAG_VALIDATION_RECEIPT_RECEIPT_TYPE)?; + let receipt_type = RadrootsValidationReceiptType::from_label(&receipt_type_label).ok_or( + RadrootsValidationReceiptError::InvalidTag(TAG_VALIDATION_RECEIPT_RECEIPT_TYPE), + )?; validate_event_id(&listing_event_id, "tags.e.listing")?; validate_event_id(&root_event_id, "tags.e.root")?; @@ -750,9 +745,7 @@ fn required_event_marker( if matches.next().is_some() { return Err(RadrootsValidationReceiptError::InvalidTag(marker)); } - let value = tag - .get(1) - .ok_or(RadrootsValidationReceiptError::InvalidTag(marker))?; + let value = &tag[1]; validate_required_str(value, marker)?; Ok(value.clone()) } @@ -869,7 +862,10 @@ mod tests { validation_receipt_tags, validation_receipt_tags_from_tags, verify_validation_receipt_event, }; - use radroots_events::{RadrootsNostrEvent, kinds::KIND_TRADE_VALIDATION_RECEIPT, tags::TAG_D}; + use radroots_events::{ + RadrootsNostrEvent, ids::RadrootsPublicKey, kinds::KIND_TRADE_VALIDATION_RECEIPT, + tags::TAG_D, + }; fn hash32(c: char) -> String { format!("0x{}", c.to_string().repeat(64)) @@ -1037,6 +1033,24 @@ mod tests { } #[test] + fn validation_trust_policy_builders_preserve_explicit_settings() { + let trusted = RadrootsPublicKey::parse(&event_id('a')).unwrap(); + let other = RadrootsPublicKey::parse(&event_id('b')).unwrap(); + let policy = RadrootsTradeValidationTrustPolicy::production() + .with_trusted_rhi_pubkeys(vec![trusted.clone()]) + .with_allow_deterministic_none(true) + .with_require_cryptographic_proof(false); + + assert_eq!(policy.trusted_rhi_pubkey_count(), 1); + assert!(policy.trusts_rhi_pubkey(&trusted)); + assert!(!policy.trusts_rhi_pubkey(&other)); + assert!(policy.allow_deterministic_none); + assert!(!policy.require_cryptographic_proof); + assert!(RadrootsTradeValidationTrustPolicy::default().require_cryptographic_proof); + assert!(RadrootsTradeValidationTrustPolicy::explicit_dev_test().allow_deterministic_none); + } + + #[test] fn validation_receipt_validate_rejects_core_field_errors() { let mut receipt = sample_validation_receipt(); receipt.version = 2; @@ -1128,6 +1142,33 @@ mod tests { ); let mut receipt = sample_validation_receipt(); + receipt.new_state_root = "bad".to_string(); + assert_eq!( + receipt.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "new_state_root" + )) + ); + + let mut receipt = sample_validation_receipt(); + receipt.previous_state_root = "bad".to_string(); + assert_eq!( + receipt.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "previous_state_root" + )) + ); + + let mut receipt = sample_validation_receipt(); + receipt.statement.target_event_id = "bad".to_string(); + assert_eq!( + receipt.validate(), + Err(RadrootsValidationReceiptError::InvalidField( + "statement.target_event_id" + )) + ); + + let mut receipt = sample_validation_receipt(); receipt.error_bitmap = "0x00000000000000000000000000000001".to_string(); assert_eq!( receipt.validate(), @@ -1353,6 +1394,56 @@ mod tests { TAG_VALIDATION_RECEIPT_RECEIPT_TYPE )) ); + + for marker in ["root", "target"] { + let missing = tags + .iter() + .filter(|tag| tag.get(4).map(String::as_str) != Some(marker)) + .cloned() + .collect::<Vec<_>>(); + assert_eq!( + validation_receipt_tags_from_tags(&missing), + Err(RadrootsValidationReceiptError::MissingTag(marker)) + ); + } + + let mut malformed_root = tags.clone(); + malformed_root[2] = vec![ + "e".to_string(), + String::new(), + String::new(), + "root".to_string(), + ]; + assert_eq!( + validation_receipt_tags_from_tags(&malformed_root), + Err(RadrootsValidationReceiptError::MissingTag("root")) + ); + + let mut missing_root_value = tags.clone(); + missing_root_value[2] = vec![ + "e".to_string(), + String::new(), + String::new(), + "root".to_string(), + ]; + missing_root_value[2].insert(1, event_id('1')); + missing_root_value[2].remove(1); + assert_eq!( + validation_receipt_tags_from_tags(&missing_root_value), + Err(RadrootsValidationReceiptError::MissingTag("root")) + ); + + let mut malformed_target = tags.clone(); + malformed_target[3] = vec![ + "e".to_string(), + String::new(), + String::new(), + "target".to_string(), + ]; + assert_eq!( + validation_receipt_tags_from_tags(&malformed_target), + Err(RadrootsValidationReceiptError::MissingTag("target")) + ); } #[test] @@ -2015,6 +2106,30 @@ mod tests { } #[test] + fn validation_receipt_builders_reject_invalid_receipts_before_serializing() { + let mut receipt = sample_validation_receipt(); + receipt.version = 2; + let content = serde_json::to_string(&receipt).unwrap(); + + assert_eq!( + validation_receipt_canonical_content(&receipt), + Err(RadrootsValidationReceiptError::InvalidField("version")) + ); + assert_eq!( + validation_receipt_content_from_str(&content), + Err(RadrootsValidationReceiptError::InvalidField("version")) + ); + assert_eq!( + validation_receipt_tags("order-1", &receipt), + Err(RadrootsValidationReceiptError::InvalidField("version")) + ); + assert!(matches!( + validation_receipt_event_build("order-1", &receipt), + Err(RadrootsValidationReceiptError::InvalidField("version")) + )); + } + + #[test] fn validation_receipt_tag_builder_rejects_empty_order_id() { assert_eq!( validation_receipt_tags("", &sample_validation_receipt()), diff --git a/crates/trade/src/workflow.rs b/crates/trade/src/workflow.rs @@ -200,9 +200,7 @@ fn apply_validation_receipts( return; } - let Some(receipt) = valid_receipts.first() else { - return; - }; + let receipt = &valid_receipts[0]; match receipt.receipt.result { RadrootsValidationReceiptResult::Valid => { @@ -304,6 +302,7 @@ pub fn inventory_reservations_from_commitments( } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, @@ -327,6 +326,7 @@ mod tests { RadrootsGroupedOrderEventRecords, RadrootsOrderCancellationRecord, RadrootsOrderDecisionRecord, RadrootsOrderIssue, RadrootsOrderRequestRecord, RadrootsOrderRevisionDecisionRecord, RadrootsOrderRevisionProposalRecord, + RadrootsTradeLocatorProjectionResolution, }; use crate::validation_receipt::{ RadrootsTradeValidationReceipt, RadrootsValidationReceiptProof, @@ -339,8 +339,10 @@ mod tests { use super::{ RadrootsTradeWorkflowDeterministicFailure, RadrootsTradeWorkflowRecords, RadrootsTradeWorkflowState, RadrootsTradeWorkflowValidationReceiptRecord, - reduce_trade_workflow_records, + inventory_reservations_from_commitments, reduce_trade_workflow_records, + reduce_trade_workflow_records_for_trade_locator, }; + use crate::identity::RadrootsTradeLocator; const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -645,6 +647,25 @@ mod tests { assert_eq!(projection.validation_receipt_event_id, Some(event_id(9))); assert!(projection.pending_inventory_reservations.is_empty()); assert_eq!(projection.committed_inventory_reservations.len(), 1); + + let mut no_listing_expectation = workflow_records(); + no_listing_expectation.expected_listing_event_id = None; + no_listing_expectation.current_listing_event_id = None; + no_listing_expectation + .order_events + .decisions + .push(accepted_decision()); + no_listing_expectation + .validation_receipts + .push(receipt_record( + 10, + RadrootsValidationReceiptResult::Valid, + event_id(1), + event_id(2), + event_id(80), + )); + let projection = reduce_trade_workflow_records(&order_id(), no_listing_expectation); + assert_eq!(projection.status, RadrootsTradeWorkflowState::Committed); } #[test] @@ -751,4 +772,231 @@ mod tests { [RadrootsOrderIssue::ValidationReceiptTargetMismatch { .. }] )); } + + #[test] + fn workflow_locator_resolution_reports_missing_ambiguous_and_matched_records() { + let locator = RadrootsTradeLocator::from_order_id(order_id()); + + let missing = reduce_trade_workflow_records_for_trade_locator( + &locator, + RadrootsTradeWorkflowRecords::default(), + ); + assert!(matches!( + missing, + RadrootsTradeLocatorProjectionResolution::Missing { .. } + )); + + let mut ambiguous = workflow_records(); + let mut second_request = request_record(); + second_request.event_id = event_id(9); + ambiguous.order_events.requests.push(second_request); + let ambiguous_resolution = + reduce_trade_workflow_records_for_trade_locator(&locator, ambiguous); + assert!(matches!( + ambiguous_resolution, + RadrootsTradeLocatorProjectionResolution::Ambiguous { ref candidates, .. } + if candidates.len() == 2 + )); + + let mut matched = workflow_records(); + matched.order_events.decisions.push(accepted_decision()); + matched.validation_receipts.push(receipt_record( + 9, + RadrootsValidationReceiptResult::Valid, + event_id(1), + event_id(2), + event_id(80), + )); + matched.validation_receipts.push(receipt_record( + 10, + RadrootsValidationReceiptResult::Valid, + event_id(9), + event_id(2), + event_id(80), + )); + let locator = + RadrootsTradeLocator::from_order_id(order_id()).with_root_event_id(event_id(1)); + let matched_resolution = reduce_trade_workflow_records_for_trade_locator(&locator, matched); + assert!(matches!( + matched_resolution, + RadrootsTradeLocatorProjectionResolution::Projected { projection, .. } + if projection.status == RadrootsTradeWorkflowState::Committed + && projection.validation_receipt_event_id == Some(event_id(9)) + )); + } + + #[test] + fn workflow_receipt_binding_reports_each_mismatch_variant() { + let mut conflicting = workflow_records(); + conflicting.order_events.decisions.push(accepted_decision()); + conflicting.validation_receipts.push(receipt_record( + 9, + RadrootsValidationReceiptResult::Valid, + event_id(1), + event_id(2), + event_id(80), + )); + conflicting.validation_receipts.push(receipt_record( + 10, + RadrootsValidationReceiptResult::Valid, + event_id(1), + event_id(2), + event_id(80), + )); + let projection = reduce_trade_workflow_records(&order_id(), conflicting); + assert!(matches!( + projection.issues.as_slice(), + [RadrootsOrderIssue::ConflictingValidationReceipts { .. }] + )); + + let mut no_pending_agreement = workflow_records(); + no_pending_agreement + .validation_receipts + .push(receipt_record( + 9, + RadrootsValidationReceiptResult::Valid, + event_id(1), + event_id(2), + event_id(80), + )); + let projection = reduce_trade_workflow_records(&order_id(), no_pending_agreement); + assert!(matches!( + projection.issues.as_slice(), + [ + RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement { .. }, + .. + ] + )); + + let mut wrong_order = workflow_records(); + wrong_order.order_events.decisions.push(accepted_decision()); + let mut receipt = receipt_record( + 9, + RadrootsValidationReceiptResult::Valid, + event_id(1), + event_id(2), + event_id(80), + ); + receipt.order_id = RadrootsOrderId::parse("order-2").unwrap(); + wrong_order.validation_receipts.push(receipt); + let projection = reduce_trade_workflow_records(&order_id(), wrong_order); + assert!(projection.issues.iter().any(|issue| { + matches!( + issue, + RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { .. } + ) + })); + + let mut wrong_order_tag = workflow_records(); + wrong_order_tag + .order_events + .decisions + .push(accepted_decision()); + let mut receipt = receipt_record( + 10, + RadrootsValidationReceiptResult::Valid, + event_id(1), + event_id(2), + event_id(80), + ); + receipt.tags.order_id = "order-2".to_string(); + wrong_order_tag.validation_receipts.push(receipt); + let projection = reduce_trade_workflow_records(&order_id(), wrong_order_tag); + assert!(projection.issues.iter().any(|issue| { + matches!( + issue, + RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { .. } + ) + })); + + let mut wrong_type = workflow_records(); + wrong_type.order_events.decisions.push(accepted_decision()); + let mut receipt = receipt_record( + 9, + RadrootsValidationReceiptResult::Valid, + event_id(1), + event_id(2), + event_id(80), + ); + receipt.receipt.receipt_type = RadrootsValidationReceiptType::ListingValidation; + wrong_type.validation_receipts.push(receipt); + let projection = reduce_trade_workflow_records(&order_id(), wrong_type); + assert!(projection.issues.iter().any(|issue| { + matches!( + issue, + RadrootsOrderIssue::ValidationReceiptTypeMismatch { .. } + ) + })); + + let mut wrong_type_tag = workflow_records(); + wrong_type_tag + .order_events + .decisions + .push(accepted_decision()); + let mut receipt = receipt_record( + 10, + RadrootsValidationReceiptResult::Valid, + event_id(1), + event_id(2), + event_id(80), + ); + receipt.tags.receipt_type = RadrootsValidationReceiptType::ListingValidation; + wrong_type_tag.validation_receipts.push(receipt); + let projection = reduce_trade_workflow_records(&order_id(), wrong_type_tag); + assert!(projection.issues.iter().any(|issue| { + matches!( + issue, + RadrootsOrderIssue::ValidationReceiptTypeMismatch { .. } + ) + })); + + let mut wrong_root = workflow_records(); + wrong_root.order_events.decisions.push(accepted_decision()); + wrong_root.validation_receipts.push(receipt_record( + 9, + RadrootsValidationReceiptResult::Valid, + event_id(3), + event_id(2), + event_id(80), + )); + let projection = reduce_trade_workflow_records(&order_id(), wrong_root); + assert!(projection.issues.iter().any(|issue| { + matches!( + issue, + RadrootsOrderIssue::ValidationReceiptRootMismatch { .. } + ) + })); + + let mut wrong_listing = workflow_records(); + wrong_listing + .order_events + .decisions + .push(accepted_decision()); + wrong_listing.validation_receipts.push(receipt_record( + 9, + RadrootsValidationReceiptResult::Valid, + event_id(1), + event_id(2), + event_id(81), + )); + let projection = reduce_trade_workflow_records(&order_id(), wrong_listing); + assert!(projection.issues.iter().any(|issue| { + matches!( + issue, + RadrootsOrderIssue::ValidationReceiptListingMismatch { .. } + ) + })); + + let reservations = inventory_reservations_from_commitments(&[ + RadrootsOrderInventoryCommitment { + bin_id: bin_id("bin-2"), + bin_count: 1, + }, + RadrootsOrderInventoryCommitment { + bin_id: bin_id("bin-1"), + bin_count: 1, + }, + ]); + assert_eq!(reservations[0].bin_id, bin_id("bin-1")); + } } diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -69,6 +69,7 @@ fn run_release(args: &[String]) -> Result<(), String> { } } +#[cfg_attr(coverage_nightly, coverage(off))] fn run_contract(args: &[String]) -> Result<(), String> { match args.first().map(String::as_str) { Some("validate") => validate_contract(),